Forgejo
Forgejo is a self-hosted Git forge (a Gitea fork) with a PostgreSQL database, HTTPS access, SSH on a second port, and package and LFS support. In this example it is reachable only on your tailnet.
What you'll learn
x-clouduler.expose: publishing a raw TCP port (SSH) that follows the container between nodes.max_body_sizeandresponse_timeoutfor large pushes.depends_onand what it does and does not wait for.- Wildcard entries in an
allowedlist. - The
__FILEenvironment convention for reading secrets from files. - Secrets that you generate with the application's own tool.
Prerequisites
Cluster. Networks tailscale (internal, domain home.example.com) and wan (egress). Drives fast (database) and apps (Forgejo data). See the cluster prerequisites.
DNS. forgejo.home.example.com, tailnet only. Your devices must resolve it to a node's tailnet address.
Secrets.
| Name | What it is | Suggested setting |
|---|---|---|
forgejo_db_password | PostgreSQL password for the forgejo user. | Generate, alphanumeric, 32 |
forgejo_secret_key | Forgejo's main secret key. | Generate, alphanumeric, 64 |
forgejo_internal_token | Token for calls between Forgejo's own processes. | Generate with the image's tool, see below |
forgejo_lfs_jwt_secret | Signs LFS tokens. | Generate with the image's tool, see below |
For the last two, use the generator that ships with Forgejo and pipe its output straight into Clouduler. clouduler secret set reads the value from stdin and removes one trailing newline by default:
docker run --rm codeberg.org/forgejo/forgejo:10 forgejo generate secret INTERNAL_TOKEN \
| clouduler secret set forgejo_internal_token
docker run --rm codeberg.org/forgejo/forgejo:10 forgejo generate secret LFS_JWT_SECRET \
| clouduler secret set forgejo_lfs_jwt_secret
Configs. None.
Volumes. fast/forgejo-db (PostgreSQL data) and apps/forgejo-data (repositories, LFS objects, packages and state).
Stack YAML
networks:
# Hardened: Forgejo only needs its own database and its own route. Other
# stacks' routes on tailscale get a 403 from the proxy, and the firewall drops
# other overlay traffic leaving the stack. Its own hostname stays listed for
# self-calls (webhooks, links back to itself). Outside (wan), only GitHub is
# reachable (migrations, mirrors).
tailscale:
allowed:
- forgejo.home.example.com
wan:
allowed:
- github.com
- "*.github.com"
- "*.githubusercontent.com"
secrets:
forgejo_db_password:
forgejo_secret_key:
forgejo_internal_token:
forgejo_lfs_jwt_secret:
services:
db:
image: postgres:16-alpine
stop_signal: SIGINT
stop_grace_period: 120s
networks: [tailscale]
volumes:
- fast/forgejo-db:/var/lib/postgresql/data
environment:
POSTGRES_USER: forgejo
POSTGRES_DB: forgejo
POSTGRES_PASSWORD_FILE: /run/secrets/forgejo_db_password
secrets: [forgejo_db_password]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forgejo"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
deploy:
replicas: 1
resources:
limits:
memory: 512M
# Reset WAL is the last resort for "invalid checkpoint record" or a crash
# loop after WAL corruption. It only runs while the container is up. If it
# crash-loops first, freeze the volume and run pg_resetwal from a one-off
# container. Checkpoint runs before each replication of the volume.
x-clouduler:
actions:
Reset WAL:
cmd: pg_resetwal "$PGDATA"
user: postgres
timeout: 1m
confirm: true
Checkpoint:
cmd: psql -U forgejo -d forgejo -c CHECKPOINT
timeout: 2m
volume_sync:
forgejo-db:
before: Checkpoint
forgejo:
image: codeberg.org/forgejo/forgejo:10
networks: [tailscale, wan]
environment:
APP_NAME: "Homelab Forge"
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
FORGEJO__database__NAME: forgejo
FORGEJO__database__USER: forgejo
FORGEJO__database__PASSWD__FILE: /run/secrets/forgejo_db_password
FORGEJO__server__DOMAIN: forgejo.home.example.com
FORGEJO__server__ROOT_URL: https://forgejo.home.example.com
FORGEJO__server__SSH_DOMAIN: forgejo.home.example.com
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__server__PER_WRITE_TIMEOUT: 10m
FORGEJO__server__PER_WRITE_PER_KB_TIMEOUT: 30s
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
FORGEJO__security__SECRET_KEY__FILE: /run/secrets/forgejo_secret_key
FORGEJO__security__INTERNAL_TOKEN__FILE: /run/secrets/forgejo_internal_token
FORGEJO__lfs__LFS_JWT_SECRET__FILE: /run/secrets/forgejo_lfs_jwt_secret
FORGEJO__log__LEVEL: Info
FORGEJO__packages__ENABLED: "true"
secrets:
- forgejo_db_password
- forgejo_secret_key
- forgejo_internal_token
- forgejo_lfs_jwt_secret
volumes:
- apps/forgejo-data:/data
depends_on: [db]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
restart: unless-stopped
deploy:
replicas: 1
resources:
limits:
memory: 1G
# Doctor check is read-only. Regenerate SSH keys rewrites authorized_keys.
# Regenerate hooks fixes git hooks after a path or config change.
x-clouduler:
actions:
Doctor check:
cmd: forgejo doctor check
user: git
timeout: 2m
Regenerate SSH keys:
cmd: forgejo admin regenerate keys
user: git
confirm: true
Regenerate hooks:
cmd: forgejo admin regenerate hooks
user: git
timeout: 2m
confirm: true
proxy:
- host: forgejo
network: tailscale
port: 3000
tls: true
max_body_size: 20GB
websockets: true
response_timeout: 10m
expose:
- port: 22
published: 2222
protocol: tcp
network: tailscale
Config files
None.
Walkthrough
networks.tailscale.allowedlists Forgejo's own hostname. Forgejo calls itself for webhooks, so its name stays on the list, and everything else on the tailnet is refused.networks.wan.allowedlists GitHub and its subdomains (*.github.comis a wildcard suffix). Migrations and mirrors from any other host fail.FORGEJO__...__FILEvariables: Forgejo reads each value from the file at that path. The secrets are mounted under/run/secrets/, so nothing sensitive sits in the environment.apps/forgejo-data:/datais the single data volume, on a replicated drive. See Drives and volumes.depends_on: [db]: Forgejo is not first created until the database has at least one instance running on any node. It waits for running, not healthy.proxy:max_body_size: 20GBallows large pushes and LFS uploads,response_timeout: 10mgives slow pushes room before the proxy gives up, andwebsockets: truesupports live updates.exposepublishes port 22 of the container as port 2222 on thetailscalenetwork. Unlikeports, it follows the container to whichever node runs it.FORGEJO__server__SSH_PORT: "2222"is what Forgejo prints in clone URLs, so it must matchpublished. See Networking.actionsrun as thegituser. Both regenerate actions ask for confirmation.
Deploy
- Web UI
- CLI / config
- Open Settings, Secrets and configs. Create
forgejo_db_passwordandforgejo_secret_keywith Generate (alphanumeric,32and64). Create the other two with Paste string or File, with the output of Forgejo's generator. - Open Stacks, Deploy stack, name it
forgejo, paste the YAML. - Click Deploy forgejo once the spec is valid. If it complains about the
exposeport, see the quirks below.
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32 | clouduler secret set forgejo_db_password
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 64 | clouduler secret set forgejo_secret_key
# forgejo_internal_token and forgejo_lfs_jwt_secret: see the commands above
clouduler stack deploy ./forgejo.yaml
First run
-
Open
https://forgejo.home.example.comfrom a tailnet device. Registration is disabled, so there is no sign-up form. The installer page may appear on the first visit. -
Create the admin account from the Containers tab, as the
gituser, with a random password that is printed once:clouduler exec forgejo.forgejo.0 -u git -- forgejo admin user create \The flags match
forgejo admin user create --helpin Forgejo 10. -
Run Doctor check and read the output before you run either regenerate action.
-
Add your public key in the web UI, then test SSH from a tailnet device:
ssh -p 2222 [email protected].
Quirks
depends_onwaits for running only. Forgejo can start while PostgreSQL is still initialising and will retry.- Database on a replicated drive. Like the other examples, it has a
Checkpointhook. Replication is not a backup. - Memory. 512M for the database and 1G for Forgejo are starting points.
- Mirrors from other hosts fail with a denied connection, not a DNS error, because of the
wanallow list. - App name.
Homelab Forgeis just a display string.