Forgejo in Compose: Built-in SSH, Push-to-Create, and LFS
One Compose service driven by FORGEJO__ env vars: built-in loopback SSH, push-to-create, LFS, and a healthcheck that tells the truth.
This afternoon I pushed a revert commit to my agent repo: the Forgejo config I had just used to open LAN access got closed again, back to loopback-only. Slightly odd indeed, breaking it and tidying it back within one hour. But that is exactly the point. Yesterday I covered why I need Forgejo as a secondary git host; today is the more practical part: the entire setup is just 20 lines of environment in Compose, no separate config file, no SSH proxy on the host. And it turns out the container's built-in SSH alone is enough, no need to expose HTTP to the network at all.
Built-in SSH: Tell the Advertised Port Apart From the Listen Port
My first mistake was still thinking in the old pattern: host SSH, inject keys, proxy to the container. Hassle. Forgejo ships a built-in SSH server that you enable with one environment variable, FORGEJO__server__START_SSH_SERVER=true. What confused me at first: two ports that look alike but do different jobs. SSH_LISTEN_PORT is the port where the built-in SSH server actually listens inside the container [3]. SSH_PORT is the port advertised in the clone URLs shown in the web UI [3]. My container listens on 22 (its internal port), but what gets advertised is 3122, because on the host, 3122 is the published port.
forgejo:
image: codeberg.org/forgejo/forgejo:16
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DISABLE_SSH: "false"
FORGEJO__server__START_SSH_SERVER: "true"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__server__SSH_PORT: "3122"
FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true"
FORGEJO__lfs__LFS_START_SERVER: "true"
ports:
- "3111:3000" # HTTP, host loopback bind only
- "3122:22" # built-in SSH, host loopback bind
volumes:
- ./forgejo-data:/data
The easy-to-miss detail: this container image reads env variables in the FORGEJO__[SECTION]__[KEY] format and writes them into app.ini [1], so the whole config above is official and documented, not a dark trick. The dark trick hides somewhere else: forgetting to set USER_UID and USER_GID to match the ownership of the volume folder. The docs are explicit, with wrong volume permissions the container may refuse to start [1]. I use 1000:1000 with a forgejo-data bind mount. A bonus I like: with no database configuration, Forgejo falls straight back to SQLite [1], so a personal forge needs no extra Postgres service.
Push-to-Create: New Repositories Without Opening the Web UI
The feature that changed my workflow the most is push-to-create: git push to a repo URL that does not exist yet, and the repo gets created on the spot. The feature ships disabled [2]. For a single-user forge like mine, one line, FORGEJO__repository__ENABLE_PUSH_CREATE_USER=true, a restart, done. The docs are honest about the requirement too: this is not an anonymous door. Whoever pushes must already be authenticated as a user, and the repo is created under that user's name [2].
One consequence to be aware of before using it: the visibility of a push-to-create repo follows DEFAULT_PUSH_CREATE_PRIVATE, which defaults to private [3]. Want it public right away? Push options do it without touching the UI: git push -o repo.private=false -u origin main [2]. For me the private default is exactly right; private backups do not need a re-declaration every time I create a repo.
LFS and the Healthcheck: Two Lines Often Treated as Optional
The last two lines are, in my opinion, mandatory from day one, not later. First FORGEJO__lfs__LFS_START_SERVER=true, because the default is off [3] and without it the LFS server never comes up, while the entire reason I moved big files to Forgejo is its LFS. The quota is the disk bind mount itself, so the size of the forgejo-data folder is the real limit [1]. Second, the healthcheck. I once pointed wget at the front page and got a healthy status while the forge was not even fully installed. The /api/healthz endpoint exists precisely for this case: it is registered on the install routes so a Docker healthcheck can still work before the instance is fully ready [4]. My compose healthcheck now wgets that path over the container loopback, every 30 seconds.
About this afternoon's revert: the call was right. HTTP stays loopback-only, laptop access goes through SSH on 3122, and Forgejo pulls all the work. Eight lines of env to maintain, all readable on one screen, and not a single one I need to memorize twice.