Files
halobestie-clone/backend/DEPLOY.md
Ramadhan Sjamsani 91bdbd5289 build(backend): Dockerize for self-hosted deploy + deploy/log docs
Backend deploy target is self-hosted Docker (VPS / Kubernetes / Docker
Engine), not Cloud Run. Add a multi-stage Dockerfile (Node 20, bcrypt
compiled in build stage, non-root runtime), .dockerignore, a staging
docker-compose, and DEPLOY.md covering install, build, migrate, run, and
log mapping/rotation. Pin engines.node>=20. Update deployment.md runbook
and backend/CLAUDE.md infra line off Cloud Run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:10:59 +08:00

8.8 KiB

Backend — Docker Deployment Guide

Operational guide for building, deploying, and observing the Halo Bestie backend as a Docker container on self-hosted infra (VPS, Docker Engine, or Kubernetes). Not Cloud Run / serverless.

Architecture / env-var reference: ../requirement/deployment.md · .env.example


1. What gets deployed

A single image (multi-stage Dockerfile) running node src/server.js, which starts two listeners (src/server.js):

Listener Bind Port Exposed?
Public API (client_app + mitra_app) 0.0.0.0 PUBLIC_PORT (default 3000) Yes — publish this
Internal API (control center) INTERNAL_HOST (default 127.0.0.1) INTERNAL_PORT (default 3001) No — loopback only, never publish

Runtime image: Node 20 (bookworm-slim), prod-only deps, runs as non-root node, native bcrypt precompiled in the build stage.


2. Install Docker (one-time, on the host)

Ubuntu / Debian VPS

# Remove any old packages, then install Docker Engine from the official repo
curl -fsSL https://get.docker.com | sh

# Run docker as your user without sudo (log out/in afterward)
sudo usermod -aG docker "$USER"

# Verify
docker version
docker compose version   # Compose v2 ships as a plugin with modern Docker

Kubernetes

No Docker Engine needed on nodes — just push the image to a registry your cluster can pull from (see §4) and apply your manifests (§7).


3. Configure environment

Create backend/.env.staging (or .env.production) from the template — never commit it:

cp .env.example .env.staging

Fill in at minimum (full list in .env.example):

Var Notes
PUBLIC_PORT 3000 (keep default unless your proxy expects otherwise)
INTERNAL_HOST / INTERNAL_PORT leave default 127.0.0.1:3001 — keeps control center private
DATABASE_URL Postgres connection string
VALKEY_URL redis://<host>:6379
SERVER_TZ UTC
AUTH_JWT_SECRET fresh per environment — never reuse prod's
FIREBASE_SERVICE_ACCOUNT_PATH path to the mounted SA JSON, e.g. /secrets/firebase-sa.json. Must be from the env's Firebase project (staging = my-bestie-876ec)
XENDIT_ENABLED false until test keys + webhook are wired
CC_ORIGIN, ADMIN_EMAIL, ADMIN_PASSWORD control-center access

Secrets (.env, the Firebase SA JSON) are provided at runtime via --env-file / volume mounts / k8s Secrets. They are not baked into the image (.dockerignore excludes .env*).


4. Build & push the image

# From the repo root
docker build -t <registry>/halobestie-backend:staging ./backend

# Push to your registry (Docker Hub, GHCR, GCP Artifact Registry, self-hosted, …)
docker push <registry>/halobestie-backend:staging

For a purely single-host setup you can skip the registry and build directly on the host.


5. Run database migrations (one-off)

Run before (re)starting the service. Never auto-migrate on container boot — concurrent replicas would race.

# Migrate (every deploy that includes new migrations)
docker run --rm --env-file backend/.env.staging \
  <registry>/halobestie-backend:staging node src/db/migrate.js

# Seed (first deploy only)
docker run --rm --env-file backend/.env.staging \
  <registry>/halobestie-backend:staging node src/db/seed.js

6. Deploy — plain Docker Engine

docker run -d --name halobestie-staging \
  --env-file backend/.env.staging \
  -p 3000:3000 \
  -v /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro \
  --restart unless-stopped \
  --log-driver json-file --log-opt max-size=10m --log-opt max-file=5 \
  <registry>/halobestie-backend:staging
  • Publish only 3000. Do not map 3001.
  • --log-opt enables log rotation — see §8.
  • Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for https://staging-api.halobestie.com. WebSocket upgrade must be proxied (the apps use /api/shared/ws).

Or with Docker Compose

A ready-to-use docker-compose.staging.yml is included (backend only — Postgres/Valkey are expected via DATABASE_URL/VALKEY_URL). It publishes only 3000, mounts the Firebase SA + log volume, and sets json-file rotation. Point it at your image via the BACKEND_IMAGE env var (or uncomment build: . to build on the host):

cd backend
cp .env.example .env.staging          # then fill it in
BACKEND_IMAGE=<registry>/halobestie-backend:staging \
  docker compose -f docker-compose.staging.yml up -d

7. Deploy — Kubernetes (sketch)

  • Deployment with the image, envFrom a Secret/ConfigMap, the Firebase SA JSON mounted from a Secret volume at /secrets/firebase-sa.json.
  • Service exposing only container port 3000. Liveness/readiness probes: tcpSocket: { port: 3000 } (no HTTP health route — TCP probe matches the Dockerfile HEALTHCHECK).
  • Migrations: a one-off Job (command: ["node","src/db/migrate.js"]) run before rolling out, not an initContainer on every pod.
  • Logs: pods write to stdout (§8) — your cluster's node agent (Fluent Bit / Loki / Cloud Logging) collects them automatically.

8. Logs — where they go and how to map them

8a. Application logs → stdout/stderr (the Docker-native way)

The backend uses Fastify's pino logger (logger: true in src/app.public.js / src/app.internal.js), emitting structured JSON to stdout, plus a few console.log lifecycle lines. It does not write its own app-log files. So:

# Tail live
docker logs -f halobestie-staging

# Last 200 lines
docker logs --tail 200 halobestie-staging

# Pretty-print the JSON (pino output is one JSON object per line)
docker logs -f halobestie-staging | jq .

# Compose equivalent
docker compose -f docker-compose.staging.yml logs -f backend

Rotation (important — default json-file logs grow unbounded): set it per-container as in §6 (--log-opt max-size=10m --log-opt max-file=5), or globally in /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "5" }
}

then sudo systemctl restart docker.

Ship logs off-host (optional): point Docker at a log driver instead of/alongside json-file — e.g. --log-driver=loki, --log-driver=fluentd, --log-driver=syslog, or --log-driver=gcplogs. On k8s, stdout is collected by the node logging agent; no per-container config needed.

Persist raw stdout to a host file (simple VPS option):

docker run -d --name halobestie-staging ... \
  <image> > /var/log/halobestie/backend.log 2>&1
# better: use the json-file driver (above) and read /var/lib/docker/containers/<id>/<id>-json.log,
# or redirect via your reverse proxy / a sidecar. Prefer a real log driver for rotation.

8b. Xendit webhook fallback JSONL → needs a volume (only if enabled)

The one component that writes a file is the optional webhook fallback sink (src/services/webhook-log.service.js), off by default. When XENDIT_WEBHOOK_FALLBACK_ENABLED=true, it writes rolling JSONL to XENDIT_WEBHOOK_FALLBACK_DIR (default ./logs/app/logs in the container). To keep those across restarts, mount a volume:

docker run -d ... \
  -e XENDIT_WEBHOOK_FALLBACK_ENABLED=true \
  -e XENDIT_WEBHOOK_FALLBACK_DIR=/app/logs \
  -v /opt/halobestie/logs:/app/logs \
  <image>

(The Compose example in §6 already declares the backend-logs volume for this.) If the fallback stays disabled, you don't need this volume — everything is on stdout.


9. Health, upgrade, rollback

# Health — the image has a built-in TCP HEALTHCHECK; check it:
docker inspect --format '{{.State.Health.Status}}' halobestie-staging

# Upgrade to a new image
docker pull <registry>/halobestie-backend:staging
docker run ... node src/db/migrate.js          # if new migrations
docker stop halobestie-staging && docker rm halobestie-staging
docker run -d --name halobestie-staging ...    # re-run with the new image

# Rollback = re-run the previous tag/digest. Graceful shutdown is handled:
# server.js traps SIGTERM and drains the listeners before exit.

10. Quick reference

Task Command
Build docker build -t <img> ./backend
Migrate docker run --rm --env-file .env.staging <img> node src/db/migrate.js
Run docker run -d --name halobestie-staging --env-file .env.staging -p 3000:3000 --restart unless-stopped <img>
Logs (live) docker logs -f halobestie-staging
Logs (pretty) docker logs -f halobestie-staging | jq .
Health docker inspect --format '{{.State.Health.Status}}' halobestie-staging
Shell in docker exec -it halobestie-staging sh