Techadamia Backend Documentation

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

  1. Container Image (Dockerfile)
  2. Docker Compose Stack
  3. Environment Variables
  4. Migrations on Startup
  5. Admin Bootstrap
  6. Health Checks
  7. Production Hardening
  8. The pgdata Volume Gotcha
  9. 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 with CGO_ENABLED=0 GOOS=linux go build ... ./cmd. It also pre-creates /out/data/media so the runtime volume inherits the correct nonroot ownership.
  • Runtime stage (gcr.io/distroless/static-debian12:nonroot): copies in the server binary and the pre-created /data/media, runs as the nonroot user (uid 65532), sets APP_PORT=8080 and MEDIA_STORAGE_DIR=/data/media, declares VOLUME ["/data/media"], EXPOSE 8080, and uses ENTRYPOINT ["/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 from DB_USER / DB_PASSWORD / DB_NAME (defaults postgres / password / techadamia). Persists to the named volume pgdata. Healthcheck: pg_isready.
  • redis - redis:7-alpine, started with persistence disabled (--save "" --appendonly no). Healthcheck: redis-cli ping.
  • app - built from the Dockerfile. It depends_on both db and redis with condition: service_healthy, so it only starts once they pass their healthchecks. Uploads persist to the media named 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=disable
  • RATE_LIMIT_REDIS_URL=redis://redis:6379/0
  • APP_PORT=8080, MEDIA_STORAGE_DIR=/data/media
  • APP_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 when APP_ENV=production; the server refuses to start otherwise. Use SESSION_COOKIE_SAMESITE=none only together with Secure (for cross-origin browser clients).
  • Bootstrap token hygiene - set BOOTSTRAP_TOKEN for the first deploy, create the admin, then clear it.
  • HSTS - configure HSTS_MAX_AGE / HSTS_INCLUDE_SUBDOMAINS / HSTS_PRELOAD once you terminate TLS in front of the app.
  • Behind a proxy - set TRUSTED_PROXIES to the proxy's IP/CIDR so client IPs (used for rate limiting and logging) are read correctly from X-Forwarded-For.
  • Multi-instance rate limiting - in-memory limits are per-process and can be bypassed across replicas. Set RATE_LIMIT_REDIS_URL to 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 Postgres max_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 /events is a long-lived stream. The handler disables nginx buffering via X-Accel-Buffering: no and sends heartbeats every 25s, but make sure your proxy's read/idle timeout for this route exceeds the heartbeat interval (e.g. nginx proxy_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 set APP_PUBLIC_URL so 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

  1. Check docker compose logs app for a config validation error (the server exits non-zero on invalid config).
  2. Confirm db and redis are healthy: docker compose ps.
  3. 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

Generated from DEPLOYMENT.md on 2026-08-17 23:37 CEST. Edit the Markdown source and re-run task docs to update.