Production Patterns
This page is a guided tour of example-code/forgejo-deployment,
a working production-quality deployment of Forgejo
(self-hosted Git) using the real Forgejo image from the official
data.forgejo.org registry. The example was driven end-to-end against
live Docker; the patterns here are what survived.
The full source is at example-code/forgejo-deployment/main.ts.
This page documents the patterns, not every line.
Lifecycle: up + verify + exit 0 then a separate --down
Perry’s runtime currently does not deliver process.on('SIGINT', ...)
to your TS code. So the canonical “Ctrl-C tears down the stack” pattern
isn’t writable today. Instead, follow the docker compose up -d /
docker compose down model: deploy + verify + exit 0, with teardown
behind a separate --down invocation:
async function main() {
const args = process.argv.slice(2);
const config = buildConfig();
if (args.includes("--down")) {
await cmdDown(config);
} else {
await cmdUp(config);
}
}
The example’s cmdUp:
- Pre-flight backend probe + port-conflict guard.
- Call
up()with the canonical spec. - Poll readiness probes (postgres
pg_isready, then forgejo/api/healthz). - Print an operator-facing banner with URLs + “how to tear down”.
- Exit 0. Containers keep running thanks to
restart: unless-stopped.
The example’s cmdDown:
- Re-call
up()with the same spec — idempotent: services already running are detected and skipped, returning the same handle the original deploy got. - Call
down(handle, { volumes: destroy }).destroyis set fromFORGEJO_DESTROY_ON_EXIT=1.
Two-network split: internal db + public web
The Forgejo example puts postgres on an internal-only network and forgejo on both that network and a public bridge:
networks: {
"forgejo-db-net": { driver: "bridge", internal: true }, // postgres unreachable from host
"forgejo-web-net": { driver: "bridge" }, // forgejo's web + SSH ports
},
services: {
db: {
networks: ["forgejo-db-net"],
// no `ports:` — postgres is invisible to the host
},
forgejo: {
networks: ["forgejo-db-net", "forgejo-web-net"],
ports: ["3000:3000", "2222:22"], // public web + SSH
},
},
Why: postgres should never be reachable from the host (or from sibling stacks), but forgejo needs both inbound HTTP from the host AND outbound DB queries to postgres. Two networks is the cleanest expression of that split.
Stable container names for cross-service DNS
Perry’s compose engine creates each container with a {md5}-{random}
derived name and doesn’t yet register the service KEY (db,
forgejo) as a network alias. So
FORGEJO__database__HOST: 'db:5432' would fail name resolution at
runtime. The Forgejo example pins explicit container_name values:
const dbHostname = "forgejo-db";
const forgejoHostname = "forgejo-app";
services: {
db: {
image: `postgres:${pgVersion}`,
container_name: dbHostname, // ← stable target
// …
},
forgejo: {
image: `data.forgejo.org/forgejo/forgejo:${version}`,
container_name: forgejoHostname,
environment: {
FORGEJO__database__HOST: `${dbHostname}:5432`, // ← refers to it
// …
},
},
},
See Networking → Cross-service DNS for the full backstory and why this is the workaround until service-key network-alias support lands.
OpenSSH on :22 + START_SSH_SERVER=false
Forgejo’s official image runs /usr/sbin/sshd on container port 22 in
its entrypoint script, then runs the forgejo binary. If you also set
FORGEJO__server__START_SSH_SERVER=true, forgejo’s Go-based built-in
SSH server tries to bind :22 too — and the container exit-0’s with
“bind: address already in use”.
The standard Forgejo deployment pattern is to let OpenSSH handle SSH on :22 and tell forgejo not to start its own:
environment: {
FORGEJO__server__START_SSH_SERVER: "false", // ← critical
FORGEJO__server__SSH_PORT: "2222", // public host port
FORGEJO__server__SSH_LISTEN_PORT: "22", // container-internal port
// …
},
- 22, then forgejo’s
gitea-shellscript.
Healthcheck-gated dependency startup
postgres takes ~5–10 seconds to initialise on first run (initdb +
listener bind). Without gating, forgejo starts immediately, can’t
connect, and burns retry budget. The fix is a per-service
healthcheck plus depends_on: { svc: { condition: 'service_healthy' } }:
db: {
image: "postgres:16-alpine",
// …
healthcheck: {
test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"],
interval: "5s",
timeout: "3s",
retries: 10,
start_period: "30s",
},
},
forgejo: {
// …
depends_on: { db: { condition: "service_healthy" } },
},
Even with that, the example also runs an explicit readiness loop
post-up() for the full HTTP /api/healthz path — the healthcheck
gates container startup but the operator banner shouldn’t print
until the API is serving:
async function waitForForgejo(stack: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
// Probe from INSIDE the forgejo container so the docker-proxy
// bind-up window doesn't trip the host-side curl.
await exec(stack, "forgejo", [
"wget", "-q", "-O", "/dev/null",
"--timeout=2", "--tries=1",
"http://127.0.0.1:3000/api/healthz",
]);
return true;
} catch (_e) {
await new Promise((r) => setTimeout(r, 2000));
}
}
return false;
}
/api/healthz is Forgejo’s no-auth liveness endpoint that returns 200
once the web server is up AND the database / cache subsystems pinged
successfully. Don’t use /api/v1/version — when
REQUIRE_SIGNIN_VIEW=true (a production-hardening default) it returns
401, and wget exits non-zero on HTTP error responses.
Stable secrets for redeploy
The Forgejo example’s buildConfig() uses truthy-fallback semantics
for env vars (process.env[name] || fallback) because Perry’s
process.env[NONEXISTENT] returns an empty-ish value where strict
equality to undefined / '' doesn’t hold:
function envOr(name: string, fallback: string): string {
return (process.env[name] as string | undefined) || fallback;
}
The defaults for the three secret-bearing fields are random hex:
dbPassword: envOr('FORGEJO_DB_PASSWORD', randomHex(32)),
secretKey: envOr('FORGEJO_SECRET_KEY', randomHex(32)),
internalT: envOr('FORGEJO_INTERNAL_TOKEN', randomHex(52)),
This is fine for first-run / dev / smoke-test, but breaks any subsequent run against the same volumes because:
- Postgres rows were authored under the prior password — new password rejects the connection.
- Forgejo’s
/data/gitea/conf/app.iniis encrypted with the priorSECRET_KEY— Forgejo can’t decrypt it on startup.
For production, set them to stable values via an .env file or a
secrets manager:
# .env
FORGEJO_DB_PASSWORD=$(openssl rand -hex 32)
FORGEJO_SECRET_KEY=$(openssl rand -hex 32)
FORGEJO_INTERNAL_TOKEN=$(openssl rand -hex 52)
# deploy.sh
source .env
./forgejo_app
Generate once, store in a secrets manager, redeploy as many times as needed against the same volumes.
First-run admin user
Forgejo’s installer is locked (INSTALL_LOCK=true) so the GUI
installer doesn’t run on first request. To create the initial admin
user, exec the forgejo admin user create CLI inside the container:
docker exec forgejo-app forgejo admin user create \
--admin --username root --email root@example.com \
--random-password
The --random-password flag prints the generated password to stdout
once — capture it from the docker logs and store it somewhere safe.
Idempotent redeploy
Running ./forgejo_app a second time on a healthy stack is a no-op:
up() calls inspect on each service, sees running, and skips. The
operator banner prints immediately and the readiness loops exit fast
because the services are already serving. This is by design — it’s
the same property docker compose up -d has.
For a “rip and replace” upgrade (new image tag, new env values that
require recreate), do an explicit --down first:
./forgejo_app --down # preserve volumes
FORGEJO_VERSION=12 ./forgejo_app # redeploy with new version
The volumes carry forward automatically; up() detects the existing
forgejo-data and forgejo-pgdata volumes via inspect_volume and
attaches them to the new containers without re-creating.
Running it
# Build perry once
cargo build --release -p perry-runtime -p perry-stdlib -p perry
# Build the example
cd example-code/forgejo-deployment
../../target/release/perry compile main.ts -o forgejo_app
# Deploy
./forgejo_app
# 🔧 Backend: docker
# 🚀 Deploying Forgejo 11 (data.forgejo.org/forgejo/forgejo:11)
# …
# 🎉 Forgejo 11 is up and ready.
# Visit http://localhost:3000/ in a browser.
# Tear down (preserves volumes for redeploy):
./forgejo_app --down
# Tear down + drop volumes (DESTROYS DATA):
FORGEJO_DESTROY_ON_EXIT=1 ./forgejo_app --down
See also
- Compose orchestration —
up()/down()reference. - Networking — the internal-net + public-net split.
- Volumes — preservation across
down(). - Security — capability hardening + image verification.