Techadamia Backend - Deployment Guide
How to build and deploy the Techadamia backend. The repo ships a multi-stage
Dockerfile and a docker-compose.yaml stack (app + PostgreSQL + Redis), which
is the supported deployment path.
Note
At a glance: build the distroless image (or use Compose), point DATABASE_URL at Postgres, set APP_ENV=production + SESSION_COOKIE_SECURE=true, and bring it up. Migrations run automatically on startup; the binary's healthcheck subcommand backs container health checks. The Production Hardening checklist is the pre-flight list.
Table of Contents
- Container Image (Dockerfile)
- Docker Compose Stack
- Environment Variables
- Migrations on Startup
- Admin Bootstrap
- Health Checks
- Production Hardening
- The pgdata Volume Gotcha
- Troubleshooting
Container Image (Dockerfile)
The Dockerfile is multi-stage:
- Build stage (
golang:1.26-alpine): downloads modules (with build caches) and builds a static binary withCGO_ENABLED=0 GOOS=linux go build ... ./cmd. It also pre-creates/out/data/mediaso the runtime volume inherits the correct nonroot ownership. - Runtime stage (
gcr.io/distroless/static-debian12:nonroot): copies in theserverbinary and the pre-created/data/media, runs as the nonroot user (uid 65532), setsAPP_PORT=8080andMEDIA_STORAGE_DIR=/data/media, declaresVOLUME ["/data/media"],EXPOSE 8080, and usesENTRYPOINT ["/app/server"].
Because the runtime image is distroless, it has no shell. That is why the
binary ships a healthcheck subcommand (see Health Checks)
instead of relying on curl.
Build and run the image standalone:
docker build -t techadamia-backend:latest .
docker run -p 8080:8080 \
-e DATABASE_URL="postgres://user:pass@db-host:5432/techadamia?sslmode=disable" \
-e CORS_ALLOWED_ORIGINS="https://app.example.com" \
-e SESSION_COOKIE_SECURE=true \
-e APP_ENV=production \
-v techadamia-media:/data/media \
techadamia-backend:latest
(APP_PORT and MEDIA_STORAGE_DIR already have image defaults.)
Docker Compose Stack
docker-compose.yaml defines three services:
- db -
postgres:16. Credentials/DB come fromDB_USER/DB_PASSWORD/DB_NAME(defaultspostgres/password/techadamia). Persists to the named volumepgdata. Healthcheck:pg_isready. - redis -
redis:7-alpine, started with persistence disabled (--save "" --appendonly no). Healthcheck:redis-cli ping. - app - built from the
Dockerfile. Itdepends_onbothdbandrediswithcondition: service_healthy, so it only starts once they pass their healthchecks. Uploads persist to themedianamed volume mounted at/data/media. Healthcheck:["/app/server", "healthcheck"].
The app service wires its environment automatically, including:
DATABASE_URL=postgres://<user>:<pass>@db:5432/<db>?sslmode=disableRATE_LIMIT_REDIS_URL=redis://redis:6379/0APP_PORT=8080,MEDIA_STORAGE_DIR=/data/mediaAPP_ENV,SESSION_COOKIE_SECURE,CORS_ALLOWED_ORIGINS,BOOTSTRAP_TOKEN(passed through from your shell /.env, with development-friendly defaults)
Bring the stack up (generating a one-time bootstrap token for the first admin):
BOOTSTRAP_TOKEN=$(openssl rand -hex 32) docker compose up --build
This produces a healthy app + postgres + redis stack. Override the pass-through variables as needed, e.g.:
APP_ENV=production \
SESSION_COOKIE_SECURE=true \
CORS_ALLOWED_ORIGINS=https://app.example.com \
DB_PASSWORD=$(openssl rand -hex 16) \
BOOTSTRAP_TOKEN=$(openssl rand -hex 32) \
docker compose up --build -d
Environment Variables
The full, authoritative list with defaults lives in .env.example. The most
important ones for deployment:
Required
| Variable | Notes |
|---|---|
DATABASE_URL |
PostgreSQL connection string. |
APP_PORT |
Port the server listens on (image default 8080). |
MEDIA_STORAGE_DIR |
Writable dir for uploads (image default /data/media). |
SESSION_COOKIE_SECURE |
Must be true when APP_ENV=production. |
CORS_ALLOWED_ORIGINS |
Comma-separated allowed frontend origins. |
Important optional
| Variable | Default | Notes |
|---|---|---|
APP_ENV |
development |
production switches logs to JSON and enforces secure cookies. |
BOOTSTRAP_TOKEN |
(empty) | Required to call POST /admin/bootstrap. Clear after first admin exists. |
RATE_LIMIT_REDIS_URL |
(empty) | Enables shared Redis rate limiting across instances. |
TRUSTED_PROXIES |
(empty) | IP/CIDR list trusted for X-Forwarded-For when behind a proxy. |
SESSION_COOKIE_SAMESITE |
lax |
lax / strict / none (none requires Secure=true). |
SESSION_COOKIE_DOMAIN |
(empty) | Cookie domain, e.g. .example.com. |
SESSION_CLEANUP_INTERVAL |
1h |
Expired-session purge interval. |
MEDIA_MAX_BYTES |
10000000 |
Max upload size in bytes. |
MEDIA_ALLOWED_MIME |
image/jpeg,image/png,image/webp |
Allowed upload MIME types. |
MAX_REQUEST_BODY_BYTES |
1048576 |
Body cap for non-media routes (media route exempt). |
CORS_ALLOWED_METHODS / CORS_ALLOWED_HEADERS / CORS_ALLOW_CREDENTIALS |
see .env.example |
CORS tuning. |
RATE_LIMIT_LOGIN / _REGISTER / _MEDIA / _ADMIN / _WRITE |
see .env.example |
Per-route requests/duration limits. |
HSTS_MAX_AGE / HSTS_INCLUDE_SUBDOMAINS / HSTS_PRELOAD |
31536000 / true / false |
HSTS header. |
REFERRER_POLICY / PERMISSIONS_POLICY / CONTENT_SECURITY_POLICY |
see .env.example |
Security headers. |
DB_MAX_CONNS / DB_MIN_CONNS |
20 / 2 |
Connection pool sizing. |
DB_MAX_CONN_LIFETIME / DB_MAX_CONN_IDLE_TIME / DB_HEALTH_CHECK_PERIOD |
1h / 30m / 1m |
Pool connection lifecycle. |
SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_FROM |
(empty) / 587 |
Transactional email (password reset, email verification). Port 587 = STARTTLS, 465 = implicit TLS. Unset host = dev mode: links are logged instead of emailed and readers activate without verification. |
APP_PUBLIC_URL |
first CORS origin | Base URL used in emailed links (/reset-password?token=…, /verify-email?token=…). |
The server validates configuration on startup and exits non-zero on bad
config (missing required vars, insecure cookies in production, unwritable
MEDIA_STORAGE_DIR, invalid RATE_LIMIT_REDIS_URL / TRUSTED_PROXIES /
MEDIA_ALLOWED_MIME, etc.). Fail-fast is intentional.
Migrations on Startup
The server applies embedded SQL migrations (internal/migrate/migrations/)
automatically on every startup, in filename order, tracking applied
versions in schema_migrations. Deployments that prefer migrations as a
discrete step (e.g. an init job) can run ./server migrate, which applies
pending migrations and exits; running both is safe because migrations are
idempotent and tracked.
Migration application is guarded by a pg_advisory_lock held on a single
connection, so when multiple replicas boot simultaneously only one applies
migrations at a time - multi-replica startup is safe. Files containing the
-- +no-transaction marker run outside a transaction.
This means rolling deploys and horizontal scaling work without a separate migration job.
Admin Bootstrap
Self-registration creates an author in pending status (or a reader,
which activates via email verification); new users cannot self-promote. To
create the very first admin, set BOOTSTRAP_TOKEN and call the bootstrap
endpoint once:
curl -X POST "$BASE_URL/admin/bootstrap" \
-H "X-Bootstrap-Token: $BOOTSTRAP_TOKEN" \
-d "username=admin" \
-d "email=admin@example.com" \
-d "password=change-me-please"
The endpoint returns 409 once any admin already exists, so it is safe to
leave configured. For production, set BOOTSTRAP_TOKEN for the first deploy,
create the admin, then clear BOOTSTRAP_TOKEN to disable the endpoint
entirely.
From then on, admins activate pending authors via GET /users/pending followed
by PUT /users/:id/status with status=active.
Health Checks
The binary includes a healthcheck subcommand:
/app/server healthcheck
It performs a GET against the local /health endpoint
(http://127.0.0.1:$APP_PORT/health) and exits 0 on HTTP 200, 1 otherwise.
This backs the container HEALTHCHECK in both the compose app service and any
orchestrator, because the distroless image has no shell or curl.
Production Hardening
SESSION_COOKIE_SECURE=true- required whenAPP_ENV=production; the server refuses to start otherwise. UseSESSION_COOKIE_SAMESITE=noneonly together with Secure (for cross-origin browser clients).- Bootstrap token hygiene - set
BOOTSTRAP_TOKENfor the first deploy, create the admin, then clear it. - HSTS - configure
HSTS_MAX_AGE/HSTS_INCLUDE_SUBDOMAINS/HSTS_PRELOADonce you terminate TLS in front of the app. - Behind a proxy - set
TRUSTED_PROXIESto the proxy's IP/CIDR so client IPs (used for rate limiting and logging) are read correctly fromX-Forwarded-For. - Multi-instance rate limiting - in-memory limits are per-process and can be
bypassed across replicas. Set
RATE_LIMIT_REDIS_URLto a shared Redis so limits are enforced consistently across all instances. - DB pool sizing - tune
DB_MAX_CONNS/DB_MIN_CONNS(and the lifetime/idle/health-check knobs) to your replica count and database capacity; total connections across replicas must stay within Postgresmax_connections(consider PgBouncer for high replica counts). - TLS - terminate HTTPS at a reverse proxy / load balancer in front of the
app; the app itself serves plain HTTP on
APP_PORT. - SSE behind a proxy -
GET /eventsis a long-lived stream. The handler disables nginx buffering viaX-Accel-Buffering: noand sends heartbeats every 25s, but make sure your proxy's read/idle timeout for this route exceeds the heartbeat interval (e.g. nginxproxy_read_timeout 90s;). - Email - configure
SMTP_*before real users arrive: without it, password-reset links only appear in server logs and readers activate without email verification. Use a dedicated sender (e.g. an app password), and setAPP_PUBLIC_URLso emailed links point at the real frontend. - Realtime is single-instance - the SSE hub is in-process. If you ever run multiple replicas, users connected to replica A won't receive events triggered on replica B; you'd need sticky sessions or a shared pub/sub (Redis) first. Notifications/messages still persist and appear on refresh either way.
The pgdata Volume Gotcha
The compose db service persists data to the named volume pgdata, which
survives docker compose down and up. PostgreSQL only honors
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB when it initializes a
fresh data directory. If the volume was first created with different
credentials, changing DB_USER / DB_PASSWORD / DB_NAME later will not
re-apply them, and the app will fail to authenticate against the database.
In development, reset the volume to pick up new credentials:
docker compose down -v # deletes the pgdata (and media) volumes
docker compose up --build
Caution
docker compose down -v destroys the pgdata and media volumes (all database rows and uploaded files). Never run it against production — there, manage database credentials deliberately instead.
Troubleshooting
App container won't become healthy
- Check
docker compose logs appfor a config validation error (the server exits non-zero on invalid config). - Confirm
dbandredisare healthy:docker compose ps. - Manually probe:
docker compose exec app /app/server healthcheck.
Database authentication failures after changing credentials
Almost always the pgdata volume gotcha. Reset the volume (dev) or align credentials with the existing data directory.
Rate limits bypassed across replicas
In-memory limiting is per-process. Set RATE_LIMIT_REDIS_URL to a shared Redis.
Wrong client IPs / rate limiting by proxy IP
Set TRUSTED_PROXIES to your proxy's IP/CIDR so X-Forwarded-For is trusted.
Last Updated: 2026-07-01