forgejo-stack/docker-compose.yml
forgejo_admin 18dfc4e696 fix(forgejo): use forgejo.localhost ROOT_URL for cross-network resolution (#6)
Single hostname resolves both from browsers (RFC 6761) and inside docker (DNS alias). Fixes artifact upload ECONNREFUSED.
2026-05-17 18:30:00 +04:00

265 lines
12 KiB
YAML

name: forgejo-stack
services:
db:
image: ${POSTGRES_IMAGE}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./data/db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks: [internal]
forgejo:
image: ${FORGEJO_IMAGE}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
FORGEJO__database__NAME: ${POSTGRES_DB}
FORGEJO__database__USER: ${POSTGRES_USER}
FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD}
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_SSH_DOMAIN}
FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL}
# LOCAL_ROOT_URL is the URL Forgejo propagates to act_runner job
# containers for in-cluster API calls (artifact uploads, status
# updates, etc.). ROOT_URL is user-facing (http://localhost:3000/);
# job containers in a separate Docker container can't reach the host's
# localhost. Point this at the Forgejo container's hostname on the
# shared internal network instead.
FORGEJO__server__LOCAL_ROOT_URL: http://forgejo:3000/
FORGEJO__server__SSH_PORT: ${FORGEJO_SSH_PORT}
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__security__SECRET_KEY: ${FORGEJO_SECRET_KEY}
FORGEJO__security__INTERNAL_TOKEN: ${FORGEJO_INTERNAL_TOKEN}
FORGEJO__oauth2__JWT_SECRET: ${FORGEJO_JWT_SECRET}
FORGEJO__server__LFS_JWT_SECRET: ${FORGEJO_LFS_JWT_SECRET}
FORGEJO__webhook__ALLOWED_HOST_LIST: "*"
FORGEJO__actions__ENABLED: "true"
ports:
- "${FORGEJO_HTTP_PORT}:3000"
- "${FORGEJO_SSH_PORT}:22"
volumes:
- ./data/forgejo:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/api/v1/version"]
interval: 10s
timeout: 5s
retries: 12
# Network alias so job containers can resolve the same hostname browsers
# use. *.localhost is special: per RFC 6761 every modern browser resolves
# it to 127.0.0.1 without an /etc/hosts entry, so users still reach
# Forgejo through the host port forward. Docker DNS resolves it inside
# the internal network to this container's IP. One hostname, two
# working resolution paths. Avoids the ACTIONS_RUNTIME_URL mismatch
# that breaks artifact uploads when ROOT_URL is host-facing localhost.
networks:
internal:
aliases:
- forgejo.localhost
runner:
image: ${RUNNER_IMAGE}
restart: unless-stopped
depends_on:
forgejo:
condition: service_healthy
environment:
FORGEJO_INSTANCE_URL: http://forgejo:3000
# Token written by bootstrap into data/runner/.runner-token
DOCKER_HOST: unix:///var/run/docker.sock
RUNNER_NAME: ${RUNNER_NAME:-local-runner}
volumes:
- ./data/runner:/data
- /var/run/docker.sock:/var/run/docker.sock
# Mounted here only so `docker compose up` materialises the named volume;
# job containers consume it through `container.options` below (the host
# docker daemon sees the volume under its fixed name). Path must match
# AGENT_TOOLSDIRECTORY in catthehacker images (/opt/hostedtoolcache) so
# actions/setup-* find tools written by prior jobs.
- hostedtoolcache:/opt/hostedtoolcache
# Add the host's docker group GID so the in-container user (uid 1000) can
# talk to /var/run/docker.sock. Override via DOCKER_GID in .env if your
# host's docker group is not 984.
group_add:
- "${DOCKER_GID:-984}"
networks: [internal]
# Override entrypoint so the container waits for the registration token
# file to appear (written by bootstrap-forgejo.sh on first run), then
# registers and runs.
entrypoint:
- /bin/sh
- -c
- |
set -e
cd /data
# Image used for `ubuntu-latest` jobs. Defaults to the locally-built
# forgejo-stack/job:latest (catthehacker runner-22.04 + yarn classic);
# see runner-image/Dockerfile and setup.sh which builds it.
JOB_IMAGE="$${RUNNER_JOB_IMAGE:-forgejo-stack/job:latest}"
if [ ! -f .runner ]; then
echo "waiting for /data/.runner-token from bootstrap..."
until [ -s /data/.runner-token ]; do sleep 2; done
forgejo-runner register \
--no-interactive \
--instance "$$FORGEJO_INSTANCE_URL" \
--token "$$(cat /data/.runner-token)" \
--name "$${RUNNER_NAME:-local-runner}" \
--labels "docker:docker://$$JOB_IMAGE,ubuntu-latest:docker://$$JOB_IMAGE"
rm -f /data/.runner-token
fi
# Pin job containers to the compose network so actions/checkout can
# resolve "forgejo:3000". Default per-task bridge networks can't.
# Network name matches `name: forgejo-stack` at the top of this file —
# override with RUNNER_JOB_NETWORK in .env if the project name changes.
# Also overrides labels (image map) and capacity for already-registered
# runners without re-registration.
JOB_NET="$${RUNNER_JOB_NETWORK:-forgejo-stack_internal}"
CAPACITY="$${RUNNER_CAPACITY:-6}"
# Extra `docker run` args forwarded to every job container. Default
# mounts a shared toolcache so `actions/setup-node`, `setup-go`, etc.
# hit cache on the second job instead of re-downloading. Mount path
# must be AGENT_TOOLSDIRECTORY (=/opt/hostedtoolcache in catthehacker
# images); mounting on /opt/acttoolcache is a dead end because
# setup-* never writes there. Volume name is fixed via `name:` at the
# bottom of this file so renaming the compose project doesn't orphan
# the cache.
JOB_OPTIONS="$${RUNNER_JOB_OPTIONS:--v forgejo-stack-hostedtoolcache:/opt/hostedtoolcache}"
# Named volumes referenced from JOB_OPTIONS must be allow-listed here or
# forgejo-runner silently drops the mount with "is not a valid volume,
# will be ignored". Defaults match the cache volume above; override with
# RUNNER_VALID_VOLUMES (comma-separated) if you add more.
VALID_VOLUMES="$${RUNNER_VALID_VOLUMES:-forgejo-stack-hostedtoolcache}"
# ACTIONS_RUNTIME_URL / ACTIONS_RESULTS_URL: Forgejo 10's actions
# service emits ROOT_URL (http://localhost:3000/) as the runtime URL
# it gives to job containers. Inside a job container, localhost:3000
# is the container itself — nothing listens, artifact uploads fail
# with ECONNREFUSED. Inject the in-cluster URL via runner.envs so
# job containers see the correct host (the forgejo service on the
# shared internal network) regardless of what Forgejo emits.
# Override with RUNNER_INTERNAL_FORGEJO_URL in .env if you renamed
# the forgejo service.
INTERNAL_FORGEJO_URL="$${RUNNER_INTERNAL_FORGEJO_URL:-http://forgejo:3000/}"
cat > /data/config.yaml <<CONFIG
runner:
capacity: $$CAPACITY
envs:
ACTIONS_RUNTIME_URL: "$$INTERNAL_FORGEJO_URL"
ACTIONS_RESULTS_URL: "$$INTERNAL_FORGEJO_URL"
labels:
- "docker:docker://$$JOB_IMAGE"
- "ubuntu-latest:docker://$$JOB_IMAGE"
container:
network: $$JOB_NET
options: "$$JOB_OPTIONS"
valid_volumes:
CONFIG
# Append one YAML list entry per comma-separated volume name.
printf '%s\n' "$$VALID_VOLUMES" | tr ',' '\n' | while IFS= read -r v; do
[ -n "$$v" ] && echo " - \"$$v\"" >> /data/config.yaml
done
exec forgejo-runner daemon --config /data/config.yaml
# Watches the consumer repo's yarn.lock and rebuilds forgejo-stack/job:latest
# whenever it changes, so the in-image yarn offline cache stays current
# without manual intervention. Polls every RUNNER_REFRESH_INTERVAL seconds
# (default 300); a single iteration is ~1s when nothing changed, ~90s when
# rebuilding. Idle if RUNNER_CACHE_SEED_REPOS is unset.
runner-refresh:
image: docker:cli
restart: unless-stopped
depends_on:
forgejo:
condition: service_healthy
environment:
FORGEJO_BASE_URL: http://forgejo:3000
FORGEJO_ADMIN_TOKEN: ${FORGEJO_ADMIN_TOKEN}
RUNNER_CACHE_SEED_REPOS: ${RUNNER_CACHE_SEED_REPOS:-}
RUNNER_JOB_IMAGE: ${RUNNER_JOB_IMAGE:-forgejo-stack/job:latest}
INTERVAL: ${RUNNER_REFRESH_INTERVAL:-300}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Project dir mounted RW so the refresh script can update
# runner-image/cache-seed/ and feed it back into `docker build`.
- .:/stack
working_dir: /stack
networks: [internal]
entrypoint:
- /bin/sh
- -c
- |
set -e
# docker:cli is alpine-based and lacks bash + curl by default; the
# refresh script needs both. Install once per container lifetime.
apk add --no-cache bash curl >/dev/null
if [ -z "$$RUNNER_CACHE_SEED_REPOS" ]; then
echo "runner-refresh: RUNNER_CACHE_SEED_REPOS unset; idling"
exec sleep infinity
fi
while true; do
bash /stack/scripts/refresh-runner-image.sh || \
echo "runner-refresh: iteration failed; retrying in $$INTERVAL s"
sleep "$$INTERVAL"
done
forgejo-mcp:
image: ${FORGEJO_MCP_IMAGE}
restart: unless-stopped
depends_on:
forgejo:
condition: service_healthy
environment:
# The ronmi/forgejo-mcp image reads FORGEJOMCP_SERVER / FORGEJOMCP_TOKEN
# (single underscore — verified via `forgejo-mcp --help`).
FORGEJOMCP_SERVER: http://forgejo:3000
FORGEJOMCP_TOKEN: ${FORGEJO_MCP_TOKEN}
# IMPORTANT: ronmi/forgejo-mcp is a `FROM scratch` image — it has no shell,
# no `ls`, just `/forgejo-mcp`. The plan's `sleep infinity` entrypoint is
# therefore impossible. We instead run the binary in HTTP mode, which is
# long-lived and can be smoke-tested with curl. Claude Code itself uses
# stdio mode via a separate `docker run --rm -i ... stdio` invocation
# registered in ~/space/.mcp.json (see bootstrap-forgejo.sh).
command:
- http
- --address
- ":8080"
ports:
- "${FORGEJO_MCP_HTTP_PORT:-8181}:8080"
networks: [internal]
volumes:
# Shared `actions/setup-*` toolcache across all job containers. First run
# downloads, subsequent jobs hit cache. Fixed name keeps the volume stable
# if the compose project is renamed. Path inside containers must match
# AGENT_TOOLSDIRECTORY (=/opt/hostedtoolcache for catthehacker images).
hostedtoolcache:
name: forgejo-stack-hostedtoolcache
networks:
internal:
driver: bridge
# MTU for the internal bridge. Default 1500 works on bare-metal LAN. Lower
# this when the host's default route goes through a VPN/tunnel (WireGuard,
# OpenVPN, etc.) — otherwise large TCP packets get black-holed and any
# tarball-style downloads inside job containers (yarn install, pip, apt)
# silently hang. Check your tunnel MTU with:
# ip link show $(ip route get 1.1.1.1 | grep -oP 'dev \K\S+') | grep mtu
driver_opts:
com.docker.network.driver.mtu: "${INTERNAL_NETWORK_MTU:-1500}"