Add CORS credentials, transactional endpoints, and container deployment
CORS
cors.go never set Access-Control-Allow-Credentials, so the
cookie-authenticated API was unreadable from any cross-origin frontend:
the server answered correctly and the browser blocked the page from
reading it. Set for allowlisted origins on both the preflight and the
actual response. Three tests added.
HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
half of what a cross-origin browser call needs; SameSite is judged on
registrable domain, so a frontend on an unrelated domain gets perfect CORS
headers and still no cookie. "none" is the only value that survives that,
and validate() refuses it without the Secure flag.
The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
together with credentials, so it would break every authenticated call
rather than loosen anything.
Transactional endpoints (api-contract.md 12.1)
POST /api/v1/job-applications/{id}/hire
POST /api/v1/job-postings/{id}/assignments
Replaces two client-side loops that wrote several records with no
transaction and no rollback. Each is now one endpoint and one transaction,
built over repo.Repo so org scoping, derived columns, type casts and error
translation are not re-derived. Authorization reuses the existing policy
table rather than adding a parallel one: a workflow is exactly as
privileged as the writes it performs. 13 tests, including both rollback
paths.
Bug fix in the repository layer
repo.bindValue handled int64/int/float64/string but not int32, which is
what pgx returns for a PostgreSQL `int` column. Nothing previously read a
record and wrote one of its fields elsewhere, so it never surfaced; the
hire flow does exactly that and failed with "ai_score must be a number".
Both KindInt and KindFloat now accept the widths pgx actually produces.
Deployment
infrastructure/Dockerfile.api multi-stage, cross-compiling (BUILDPLATFORM
+ GOARCH) so linux/amd64 builds from arm64 are compiled rather than
emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
setpassword and migrate, plus the migrations, so a Kubernetes
initContainer can apply the schema from the same image and tag as the
API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
is not pulled from rotation during a migration window.
infrastructure/docker-compose.yml migrations run to completion before the
API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
with TLS enabled so APP_ENV=production is met rather than dodged.
scripts/drop_public_tables.go the one-off used to clear an unrelated
schema from krowdb on 2026-08-24, kept for the record. Build-tagged
ignore and gated on CONFIRM_DROP=yes.
Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
This commit is contained in:
95
infrastructure/.env.docker.example
Normal file
95
infrastructure/.env.docker.example
Normal file
@@ -0,0 +1,95 @@
|
||||
# ============================================================================
|
||||
# Krow API — Docker environment
|
||||
#
|
||||
# cd infrastructure && cp .env.docker.example .env
|
||||
#
|
||||
# Compose reads `.env` from the directory the compose file lives in, so this
|
||||
# copy belongs in infrastructure/, NOT the repository root .env used by
|
||||
# `make run`. Both are gitignored.
|
||||
#
|
||||
# Every value here is a placeholder. No real password or host belongs in a
|
||||
# file that is committed.
|
||||
# ============================================================================
|
||||
|
||||
# ── Application ─────────────────────────────────────────────────────────────
|
||||
APP_ENV=production # development | staging | production
|
||||
LOG_LEVEL=info # debug | info | warn | error
|
||||
VERSION=1.0.0 # build arg only
|
||||
IMAGE=doormile/krowbackend:latest # what `docker compose build` tags and `up` runs
|
||||
|
||||
# ── Where the API is published ──────────────────────────────────────────────
|
||||
# Loopback by default: put a reverse proxy in front to terminate TLS. The API
|
||||
# speaks plain HTTP and its session cookie is Secure outside development, so a
|
||||
# browser will not send that cookie back over a plain connection anyway.
|
||||
API_BIND=127.0.0.1
|
||||
API_PORT=8080
|
||||
|
||||
# Browser origins allowed to call this API, comma-separated. Exact strings:
|
||||
# scheme included, no trailing slash. Cookies are the auth mechanism, so
|
||||
# anything listed here can hold a live session.
|
||||
#
|
||||
# "*" is REJECTED at startup — browsers refuse Allow-Origin: "*" together with
|
||||
# credentials, so it would break every authenticated call rather than loosen
|
||||
# anything.
|
||||
HTTP_CORS_ORIGINS=https://platform.krowforce.com,https://mcp.krowforce.com
|
||||
|
||||
# SameSite on the session cookie: lax | none | strict.
|
||||
#
|
||||
# "lax" is right while the API is on *.krowforce.com — the two origins above
|
||||
# are then cross-ORIGIN (CORS applies) but same-SITE (the cookie is still
|
||||
# sent). Move the API to any other registrable domain and this must become
|
||||
# "none", or login will succeed and every following request will arrive
|
||||
# anonymous.
|
||||
HTTP_COOKIE_SAMESITE=lax
|
||||
|
||||
# ── Database ────────────────────────────────────────────────────────────────
|
||||
# Point at a managed PostgreSQL. With docker-compose.local-db.yml layered on
|
||||
# top, set DATABASE_HOST=postgres instead.
|
||||
DATABASE_HOST=your-database-host.example.com
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=krow
|
||||
DATABASE_USER=krow
|
||||
DATABASE_PASSWORD=change-me
|
||||
DATABASE_SCHEMA=public
|
||||
|
||||
# require encrypts but does not verify the server; verify-full also checks the
|
||||
# certificate against a CA and is what you want against a managed database.
|
||||
#
|
||||
# `disable` is REJECTED at startup when APP_ENV=production. That is deliberate.
|
||||
DATABASE_SSLMODE=require
|
||||
|
||||
# ── Migration URL ───────────────────────────────────────────────────────────
|
||||
# golang-migrate takes a single URL, and Compose cannot assemble or encode one.
|
||||
#
|
||||
# ⚠️ PERCENT-ENCODE THE PASSWORD. A password containing @ : / ? # or % will
|
||||
# otherwise be parsed as part of the host or the path, and the failure looks
|
||||
# like a wrong host rather than a wrong password:
|
||||
#
|
||||
# @ → %40 : → %3A / → %2F ? → %3F # → %23 % → %25
|
||||
#
|
||||
# python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' 'your-password'
|
||||
#
|
||||
# search_path must match DATABASE_SCHEMA above.
|
||||
DATABASE_URL=postgres://krow:change-me@your-database-host.example.com:5432/krow?sslmode=require&search_path=public
|
||||
|
||||
# ── Pool and timeouts ───────────────────────────────────────────────────────
|
||||
DATABASE_MAX_OPEN_CONNS=25
|
||||
DATABASE_MIN_IDLE_CONNS=2
|
||||
DATABASE_CONN_MAX_LIFETIME=30m
|
||||
DATABASE_CONNECT_TIMEOUT=10s
|
||||
DATABASE_STATEMENT_TIMEOUT=10s
|
||||
|
||||
# ── HTTP timeouts ───────────────────────────────────────────────────────────
|
||||
HTTP_READ_TIMEOUT=15s
|
||||
HTTP_WRITE_TIMEOUT=30s
|
||||
HTTP_IDLE_TIMEOUT=60s
|
||||
HTTP_SHUTDOWN_TIMEOUT=10s
|
||||
|
||||
# ── Container resources ─────────────────────────────────────────────────────
|
||||
API_CPU_LIMIT=2
|
||||
API_MEMORY_LIMIT=512M
|
||||
|
||||
# ── Local database only (docker-compose.local-db.yml) ───────────────────────
|
||||
POSTGRES_BIND=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_MEMORY_LIMIT=1G
|
||||
155
infrastructure/Dockerfile.api
Normal file
155
infrastructure/Dockerfile.api
Normal file
@@ -0,0 +1,155 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# ============================================================================
|
||||
# Krow API — production image
|
||||
#
|
||||
# Build from the REPOSITORY ROOT, not from this directory: the build needs
|
||||
# go-api/ and seed/, and a Dockerfile can never reach above its context.
|
||||
#
|
||||
# docker build -f infrastructure/Dockerfile.api -t krow-api:latest .
|
||||
#
|
||||
# Three binaries ship in the image, because all three are things an operator
|
||||
# needs against a running deployment and none of them justify a second image:
|
||||
#
|
||||
# /usr/local/bin/api the HTTP service (the default command)
|
||||
# /usr/local/bin/seed loads the demo fixture
|
||||
# /usr/local/bin/setpassword sets a user's password — without it a fresh
|
||||
# database has no one who can sign in
|
||||
#
|
||||
# Migrations are deliberately NOT run by this image. They are a discrete deploy
|
||||
# step against the target database BEFORE the new binary rolls out, which is
|
||||
# what makes expand/migrate/contract possible: see README.md "Staging and
|
||||
# production". docker-compose.yml runs them as their own one-shot service.
|
||||
# ============================================================================
|
||||
|
||||
# ── Build ───────────────────────────────────────────────────────────────────
|
||||
# --platform=$BUILDPLATFORM pins this stage to the MACHINE DOING THE BUILDING,
|
||||
# not the machine that will run the image. Combined with GOARCH below, that
|
||||
# turns a cross-platform build into cross-COMPILATION rather than emulation.
|
||||
#
|
||||
# It matters a lot in practice: building linux/amd64 from an Apple Silicon Mac
|
||||
# without this runs the entire Go toolchain under QEMU, which is roughly an
|
||||
# order of magnitude slower. Every binary here is CGO_ENABLED=0 and therefore
|
||||
# pure Go, so the Go compiler can target another architecture directly and there
|
||||
# is nothing emulation would buy.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
|
||||
|
||||
# Supplied automatically by buildx from --platform. Declared here so the build
|
||||
# stage can read them; TARGETARCH is "amd64", "arm64", etc.
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Dependencies first, in their own layer. go.mod and go.sum change far less
|
||||
# often than source, so an edit to a handler does not re-download the module
|
||||
# graph.
|
||||
COPY go-api/go.mod go-api/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY go-api/ ./
|
||||
|
||||
# CGO_ENABLED=0 produces a static binary, which is what lets the final stage be
|
||||
# a near-empty image with no libc to keep patched.
|
||||
# -trimpath keeps build-machine paths out of panics and binaries
|
||||
# -s -w drops the symbol table and DWARF; roughly a third off the size
|
||||
ARG VERSION=dev
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/api ./cmd/api && \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed && \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath -ldflags="-s -w" -o /out/setpassword ./cmd/setpassword
|
||||
|
||||
# The golang-migrate CLI, built here rather than pulled as a second image.
|
||||
#
|
||||
# `go install pkg@version` resolves in its own module context, so this does NOT
|
||||
# touch go.mod or go.sum. Having it in the image means the Kubernetes manifests
|
||||
# can run migrations from an initContainer using the SAME image and tag as the
|
||||
# API — so the schema and the binary that expects it can never be different
|
||||
# versions, which is the failure a separate migrate image invites.
|
||||
ARG MIGRATE_VERSION=v4.19.0
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install -tags 'postgres' \
|
||||
github.com/golang-migrate/migrate/v4/cmd/migrate@${MIGRATE_VERSION} && \
|
||||
cp "$(go env GOPATH)/bin/${TARGETOS}_${TARGETARCH}/migrate" /out/migrate 2>/dev/null || \
|
||||
cp "$(go env GOPATH)/bin/migrate" /out/migrate
|
||||
|
||||
# Fail the build rather than the deploy if the tests do not pass. Skipped by
|
||||
# default because the database-backed tests need PostgreSQL, which a build
|
||||
# container does not have; turn it on in CI where a service container does.
|
||||
# Note: this runs on the BUILD architecture, so it is only meaningful when
|
||||
# building natively. Under cross-compilation the test binaries would target the
|
||||
# other architecture and could not execute here.
|
||||
ARG RUN_TESTS=false
|
||||
RUN if [ "$RUN_TESTS" = "true" ]; then go test ./... ; fi
|
||||
|
||||
|
||||
# ── Runtime ─────────────────────────────────────────────────────────────────
|
||||
# Alpine rather than distroless, for one concrete reason: HEALTHCHECK needs a
|
||||
# command inside the container, and distroless has no shell and no wget. The
|
||||
# trade is ~8 MB and an apk surface to keep updated, in exchange for the
|
||||
# orchestrator being able to see whether this instance is actually serving.
|
||||
FROM alpine:3.20 AS runtime
|
||||
|
||||
# ca-certificates: the pgx driver needs a trust store to verify a managed
|
||||
# database's TLS certificate under sslmode=verify-full.
|
||||
# tzdata: timestamps are timestamptz and the seeder anchors shift dates to now.
|
||||
RUN apk add --no-cache ca-certificates tzdata wget && \
|
||||
adduser -D -H -u 10001 -s /sbin/nologin krow
|
||||
|
||||
COPY --from=build /out/api /usr/local/bin/api
|
||||
COPY --from=build /out/seed /usr/local/bin/seed
|
||||
COPY --from=build /out/setpassword /usr/local/bin/setpassword
|
||||
COPY --from=build /out/migrate /usr/local/bin/migrate
|
||||
|
||||
# The seed fixture, so `seed` works without a bind mount.
|
||||
COPY seed/fixtures/seed.json /app/seed/fixtures/seed.json
|
||||
ENV SEED_FIXTURE_PATH=/app/seed/fixtures/seed.json
|
||||
|
||||
# The migration files, so an initContainer can apply them from this image.
|
||||
# They are read-only at runtime and the process is non-root, so nothing here
|
||||
# can be rewritten by the service.
|
||||
COPY migrations/ /app/migrations/
|
||||
ENV MIGRATIONS_DIR=/app/migrations
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Non-root. The service binds 8080, which needs no privilege, so there is no
|
||||
# reason for this process to be able to write to its own filesystem or read
|
||||
# another container's mounts.
|
||||
USER krow:krow
|
||||
|
||||
# 0.0.0.0, NOT the 127.0.0.1 that config.Load defaults to. A container that
|
||||
# binds loopback is reachable only from inside itself — the symptom is a
|
||||
# published port that refuses every connection while the process looks healthy.
|
||||
ENV HTTP_HOST=0.0.0.0 \
|
||||
HTTP_PORT=8080 \
|
||||
APP_ENV=production \
|
||||
LOG_LEVEL=info
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# The check keys on the STATUS CODE, not on the body, because the two say
|
||||
# different things on purpose (httpserver.handleHealth):
|
||||
#
|
||||
# 200 "ok" serving normally
|
||||
# 200 "degraded" process healthy, schema unmigrated or dirty — still 200,
|
||||
# because the fault is the database's and pulling this
|
||||
# instance would not fix it
|
||||
# 503 "unavailable" database unreachable
|
||||
#
|
||||
# Grepping the body for "ok" would mark a degraded instance unhealthy and take
|
||||
# the whole deployment out of rotation during a migration window, which is
|
||||
# exactly the behaviour that 200 was chosen to avoid. wget exits non-zero on
|
||||
# 503 and zero on either 200, which is the intended semantics.
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1
|
||||
|
||||
# Exec form: the binary becomes PID 1 and receives SIGTERM directly, which is
|
||||
# what cmd/api's signal.NotifyContext is waiting for to drain connections.
|
||||
ENTRYPOINT ["/usr/local/bin/api"]
|
||||
101
infrastructure/docker-compose.local-db.yml
Normal file
101
infrastructure/docker-compose.local-db.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
# ============================================================================
|
||||
# Overlay: run PostgreSQL alongside the API.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.local-db.yml up -d
|
||||
#
|
||||
# FOR STAGING AND FOR TESTING THE STACK ON ONE HOST. Not for production data.
|
||||
# A database in a container on the same host as its application has no backup,
|
||||
# no point-in-time recovery, no failover, and dies with the host. The base file
|
||||
# assumes a managed PostgreSQL for exactly that reason.
|
||||
#
|
||||
# ── TLS ─────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# PostgreSQL is started with SSL on, using a self-signed certificate generated
|
||||
# on first boot into the data volume. That is enough for DATABASE_SSLMODE=
|
||||
# require, which encrypts the connection but does not verify who is on the
|
||||
# other end — appropriate for a private compose network, NOT a substitute for
|
||||
# verify-full against a real CA.
|
||||
#
|
||||
# It also means APP_ENV=production works here without weakening the check in
|
||||
# internal/config, which is the point: the guard stays honest and the local
|
||||
# stack meets it rather than dodging it.
|
||||
#
|
||||
# Set these in .env to match:
|
||||
# DATABASE_HOST=postgres
|
||||
# DATABASE_SSLMODE=require
|
||||
# DATABASE_URL=postgres://krow:<url-encoded-password>@postgres:5432/krow?sslmode=require
|
||||
# ============================================================================
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: krow-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${DATABASE_NAME:-krow}
|
||||
POSTGRES_USER: ${DATABASE_USER:-krow}
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
|
||||
# scram-sha-256 rather than the image's md5 default. md5 has been
|
||||
# deprecated upstream for years and is trivial to crack from a captured
|
||||
# handshake.
|
||||
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"
|
||||
# Generate a self-signed certificate on first boot, then hand off to the
|
||||
# image's own entrypoint with SSL enabled. The key must be 0600 and owned
|
||||
# by the postgres user or the server refuses to start — that check is the
|
||||
# reason this is done here rather than in a bind mount, where host
|
||||
# ownership would leak in.
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
CERT=/var/lib/postgresql/data/server.crt
|
||||
KEY=/var/lib/postgresql/data/server.key
|
||||
if [ ! -f "$$CERT" ]; then
|
||||
mkdir -p /var/lib/postgresql/data
|
||||
openssl req -new -x509 -days 3650 -nodes -text \
|
||||
-out "$$CERT" -keyout "$$KEY" -subj "/CN=postgres" 2>/dev/null
|
||||
chmod 0600 "$$KEY"
|
||||
chown postgres:postgres "$$CERT" "$$KEY"
|
||||
fi
|
||||
exec docker-entrypoint.sh postgres \
|
||||
-c ssl=on -c ssl_cert_file="$$CERT" -c ssl_key_file="$$KEY"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
# -U and -d so this reports on the application's database, not on
|
||||
# whatever `postgres` happens to be reachable.
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-krow} -d ${DATABASE_NAME:-krow}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
# Loopback only. Publishing 5432 to the world is how a database ends up
|
||||
# in someone else's botnet.
|
||||
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 60s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${POSTGRES_MEMORY_LIMIT:-1G}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
networks: [krow]
|
||||
|
||||
# The migration runner now has to wait for the database to accept
|
||||
# connections, which it does not need to do when the database is managed and
|
||||
# already up.
|
||||
migrate:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
142
infrastructure/docker-compose.yml
Normal file
142
infrastructure/docker-compose.yml
Normal file
@@ -0,0 +1,142 @@
|
||||
# ============================================================================
|
||||
# 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. "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.
|
||||
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
|
||||
Reference in New Issue
Block a user