Files
krow_backend/infrastructure/docker-compose.yml
2026-08-28 12:21:44 +05:30

151 lines
6.5 KiB
YAML

# ============================================================================
# Krow API — production stack
#
# cd infrastructure
# cp .env.docker.example .env
# docker compose up -d --build
#
# This file assumes a MANAGED PostgreSQL (RDS, Cloud SQL, Neon, a DBA's
# cluster) reachable from wherever these containers run. That is deliberate:
# a production database wants backups, point-in-time recovery, failover and a
# patch schedule, none of which a container in this file provides.
#
# To run PostgreSQL alongside the API — for staging, or to test the stack
# end-to-end on one host — layer the override on top:
#
# docker compose -f docker-compose.yml -f docker-compose.local-db.yml up -d
#
# ── ORDER OF OPERATIONS ─────────────────────────────────────────────────────
#
# `migrate` runs to completion BEFORE `api` starts, and `api` will not start if
# it fails. That is the expand/migrate/contract discipline from README.md
# expressed in the dependency graph rather than in a runbook: schema first,
# binary second, and every migration backwards-compatible with the version
# still running.
#
# ── TLS IS NOT OPTIONAL HERE ────────────────────────────────────────────────
#
# internal/config rejects DATABASE_SSLMODE=disable when APP_ENV=production, so
# this stack will refuse to start against a database that cannot do TLS. That
# is the intended behaviour and not something to work around by dropping to
# APP_ENV=staging — a staging flag on a production deployment is a lie the next
# person has to discover.
# ============================================================================
name: krow
# The database settings the API and the migration runner must agree on.
x-database-env: &database-env
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
DATABASE_PORT: ${DATABASE_PORT:-5432}
DATABASE_NAME: ${DATABASE_NAME:?DATABASE_NAME is required}
DATABASE_USER: ${DATABASE_USER:?DATABASE_USER is required}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
DATABASE_SCHEMA: ${DATABASE_SCHEMA:-public}
DATABASE_SSLMODE: ${DATABASE_SSLMODE:-require}
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
# ── Schema ────────────────────────────────────────────────────────────────
# One-shot. Exits 0 when the schema is current, including when there was
# nothing to apply. `api` waits on that exit code.
#
# DATABASE_URL is a single pre-built string rather than assembled from the
# parts above because golang-migrate takes a URL, and a password containing
# @ : / ? or # must be percent-encoded in one. Compose cannot encode it, so
# the encoding is the operator's job — see .env.docker.example.
migrate:
image: migrate/migrate:v4.19.0
container_name: krow-migrate
volumes:
- ../migrations:/migrations:ro
command:
- -path=/migrations
- -database=${DATABASE_URL:?DATABASE_URL is required — see .env.docker.example}
- up
restart: "no"
logging: *logging
networks: [krow]
# ── API ───────────────────────────────────────────────────────────────────
api:
build:
context: ..
dockerfile: infrastructure/Dockerfile.api
args:
VERSION: ${VERSION:-dev}
image: ${IMAGE:-doormile/krowbackend:latest}
container_name: krow-api
depends_on:
migrate:
condition: service_completed_successfully
environment:
<<: *database-env
APP_ENV: ${APP_ENV:-production}
LOG_LEVEL: ${LOG_LEVEL:-info}
# 0.0.0.0 so the published port actually reaches the process. The binary
# defaults to 127.0.0.1, which inside a container means "nobody".
HTTP_HOST: 0.0.0.0
HTTP_PORT: "8080"
HTTP_READ_TIMEOUT: ${HTTP_READ_TIMEOUT:-15s}
HTTP_WRITE_TIMEOUT: ${HTTP_WRITE_TIMEOUT:-30s}
HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-60s}
HTTP_SHUTDOWN_TIMEOUT: ${HTTP_SHUTDOWN_TIMEOUT:-10s}
# Browser origins allowed to call this API. Empty means same-origin only,
# and the CORS middleware is then not installed at all. Cookies are the
# auth mechanism, so any origin listed here can hold a session.
#
# "*" is refused at startup: browsers reject Allow-Origin "*" together
# with credentials, so it would break authenticated calls rather than
# loosen anything.
HTTP_CORS_ORIGINS: ${HTTP_CORS_ORIGINS:-}
# lax | none | strict, or unset to let the server choose.
#
# "none" is required when the frontend is on a different registrable
# DOMAIN from the API — otherwise the browser withholds the cookie
# however correct the CORS headers are. A different ORIGIN on the same
# domain (platform.krowforce.com → mcp.krowforce.com) needs CORS but not
# this: a Lax cookie already travels between them, and "none" would give
# up the only CSRF protection this API has.
#
# Unset, the server derives it from HTTP_CORS_ORIGINS. The default below
# is deliberate: a compose deployment keeps Lax unless told otherwise.
HTTP_COOKIE_SAMESITE: ${HTTP_COOKIE_SAMESITE:-lax}
DATABASE_MAX_OPEN_CONNS: ${DATABASE_MAX_OPEN_CONNS:-25}
DATABASE_MIN_IDLE_CONNS: ${DATABASE_MIN_IDLE_CONNS:-2}
DATABASE_CONN_MAX_LIFETIME: ${DATABASE_CONN_MAX_LIFETIME:-30m}
DATABASE_CONNECT_TIMEOUT: ${DATABASE_CONNECT_TIMEOUT:-10s}
DATABASE_STATEMENT_TIMEOUT: ${DATABASE_STATEMENT_TIMEOUT:-10s}
ports:
# Bound to loopback by default. A reverse proxy in front of this is what
# terminates TLS; publishing 0.0.0.0:8080 would put a plain-HTTP service
# carrying session cookies straight onto the network.
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
restart: unless-stopped
stop_grace_period: 30s
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:size=32m
deploy:
resources:
limits:
cpus: "${API_CPU_LIMIT:-2}"
memory: ${API_MEMORY_LIMIT:-512M}
reservations:
memory: 128M
logging: *logging
networks: [krow]
networks:
krow:
driver: bridge