docker-compose.production.yml
Rama main · réplica de lectura del repositorio original.
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Aaron K. Clark
#
# Hardened self-hosting stack. Copy .env.production.example to .env.production,
# replace every required value, then run:
# docker compose --env-file .env.production -f docker-compose.production.yml up -d
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-applytrack}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}
POSTGRES_DB: ${POSTGRES_DB:-applytrack}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- database
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-applytrack}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
api:
image: ghcr.io/cryptojones/osapplytrack-api:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version}
environment:
ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=${POSTGRES_USER:-applytrack};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}"
ASPNETCORE_URLS: "http://+:8080"
# The agent's and the poller's own least-privilege roles: the api (the owner, and
# the only migrator) creates them from these on boot and keeps their passwords and
# grants in step (#345). The agent and poller services connect as them.
AGENT_DB_PASSWORD: ${AGENT_DB_PASSWORD:?Set AGENT_DB_PASSWORD in .env.production}
POLLER_DB_PASSWORD: ${POLLER_DB_PASSWORD:?Set POLLER_DB_PASSWORD in .env.production}
App__PublicBaseUrl: ${APP_PUBLIC_BASE_URL:?Set APP_PUBLIC_BASE_URL to the public HTTPS origin}
AllowedHosts: ${ALLOWED_HOSTS:?Set ALLOWED_HOSTS to the public hostname}
Llm__BaseUrl: ${Llm__BaseUrl:-}
Llm__Model: ${Llm__Model:-}
Llm__ApiKey: ${Llm__ApiKey:-}
# Daily per-tenant cap on model requests made with the key above (0 = unlimited).
Llm__DailyCapPerTenant: ${Llm__DailyCapPerTenant:-200}
# Daily retention sweep (#350); see README · Configuration. 0 days = keep forever.
Retention__ScreenshotDays: ${Retention__ScreenshotDays:-90}
Retention__AgentEventDays: ${Retention__AgentEventDays:-180}
Retention__SeenDays: ${Retention__SeenDays:-0}
Retention__Enabled: ${Retention__Enabled:-true}
Retention__IntervalHours: ${Retention__IntervalHours:-24}
Retention__InitialDelaySeconds: ${Retention__InitialDelaySeconds:-300}
Retention__ScreenshotsKeptPerApplication: ${Retention__ScreenshotsKeptPerApplication:-3}
Retention__ExpiredGraceDays: ${Retention__ExpiredGraceDays:-1}
Retention__PollRunDays: ${Retention__PollRunDays:-30}
# Encryption at rest for résumés, letters, packets, screenshots and tenant secrets.
# Required here: the hardened containers are read-only, so the app cannot generate
# and keep a key file itself — the operator manages the key (and backs it up).
APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:?Set APPLYTRACK_SECRETS_KEY to a long random value (openssl rand -base64 48) and back it up}
# Opt-in observability (#359), empty = off: an OTLP collector, and/or a port for a
# Prometheus /metrics served on that port only (publish it to the scraper, not the
# internet). See README · Observability.
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
Metrics__Port: ${METRICS_PORT:-}
Email__Host: ${Email__Host:-}
Email__Port: ${Email__Port:-587}
Email__Username: ${Email__Username:-}
Email__Password: ${Email__Password:-}
Email__From: ${Email__From:-}
Email__FromName: ${Email__FromName:-OSApplyTrack}
ForwardedHeaders__KnownProxies__0: ${FORWARDED_HEADERS_KNOWN_PROXY:-}
ForwardedHeaders__KnownNetworks__0: ${FORWARDED_HEADERS_KNOWN_NETWORK:-}
# Keep Kestrel off the public interface. Terminate TLS with a same-host reverse
# proxy and forward it to this loopback port.
ports:
- "127.0.0.1:${API_PORT:-8080}:8080"
networks:
- database
- egress
depends_on:
db:
condition: service_healthy
user: "1654:1654"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
init: true
restart: unless-stopped
agent:
image: ghcr.io/cryptojones/osapplytrack-api:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version}
environment:
# The restricted role: what decides how bad a renderer escape in the browser
# is. It cannot migrate, so it waits for the api container to.
ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=applytrack_agent;Password=${AGENT_DB_PASSWORD:?Set AGENT_DB_PASSWORD in .env.production}"
Migrations__Mode: wait
ASPNETCORE_URLS: "http://+:8080"
Agent__Enabled: "true"
Agent__IntervalSeconds: ${AGENT_INTERVAL:-300}
Agent__LlmTimeoutSeconds: ${AGENT_LLM_TIMEOUT:-300}
Llm__BaseUrl: ${Llm__BaseUrl:-}
Llm__Model: ${Llm__Model:-}
Llm__ApiKey: ${Llm__ApiKey:-}
# Email notifications (#355) go out from here: the same SMTP relay as the api's.
Email__Host: ${Email__Host:-}
Email__Port: ${Email__Port:-587}
Email__Username: ${Email__Username:-}
Email__Password: ${Email__Password:-}
Email__From: ${Email__From:-}
Email__FromName: ${Email__FromName:-OSApplyTrack}
APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:?Set APPLYTRACK_SECRETS_KEY to a long random value (openssl rand -base64 48) and back it up}
# Opt-in observability (#359), empty = off: an OTLP collector, and/or a port for a
# Prometheus /metrics served on that port only (publish it to the scraper, not the
# internet). See README · Observability.
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
Metrics__Port: ${METRICS_PORT:-}
App__PublicBaseUrl: ${APP_PUBLIC_BASE_URL:?Set APP_PUBLIC_BASE_URL to the public HTTPS origin}
Browser__Endpoint: ws://browser:3000/
Browser__Proxy: http://proxy:3128
# No published port: this container only talks to Postgres, the model, and the browser.
networks:
- database
- egress
- browser
depends_on:
db:
condition: service_healthy
user: "1654:1654"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
init: true
restart: unless-stopped
# The browser: Playwright's server image, deliberately NOT under the hardened
# profile above (Chromium needs an executable temp dir and its sandbox needs
# user namespaces). Its containment is the network: `browser` is internal, so the
# only thing it can reach is the proxy; plus Playwright's own seccomp profile,
# a non-root user, and a fresh context per run holding no credentials.
browser:
image: ghcr.io/cryptojones/osapplytrack-browser:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version}
environment:
HTTPS_PROXY: http://proxy:3128
HTTP_PROXY: http://proxy:3128
networks:
- browser
init: true
security_opt:
- seccomp:./docker/browser/seccomp_profile.json
- no-new-privileges:true
restart: unless-stopped
# The forward proxy: does the browser's DNS, refuses private destinations.
proxy:
image: ghcr.io/cryptojones/osapplytrack-proxy:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version}
networks:
- browser
- egress
cap_drop:
- ALL
# squid starts as root only to drop to its own user; that needs exactly these.
cap_add:
- SETUID
- SETGID
- CHOWN
security_opt:
- no-new-privileges:true
init: true
restart: unless-stopped
poller:
image: ghcr.io/cryptojones/osapplytrack-poller:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version}
environment:
# The restricted role (#345): read profiles, stage leads, keep board sessions —
# no sessions, tokens, résumés or DDL. Until the api has created it on its first
# boot the poll loops just retry.
DATABASE_URL: "postgresql://applytrack_poller:${POLLER_DB_PASSWORD:?Set POLLER_DB_PASSWORD in .env.production}@db:5432/${POSTGRES_DB:-applytrack}"
DRAIN_INTERVAL: ${DRAIN_INTERVAL:-60}
POLL_INTERVAL: ${POLL_INTERVAL:-3600}
# Optional (#300): lets tenants who opt in score listings with Jev. Blank = keywords.
TYPESAFE_API_KEY: ${TYPESAFE_API_KEY:-}
# Opens the board sessions the api sealed (LinkedIn, Handshake, MyGreenhouse) (#340).
APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:?Set APPLYTRACK_SECRETS_KEY to a long random value (openssl rand -base64 48) and back it up}
networks:
- database
- egress
depends_on:
db:
condition: service_healthy
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
init: true
restart: unless-stopped
networks:
database:
internal: true
egress:
# The browser's world: no gateway, nothing reachable but the proxy.
browser:
internal: true
volumes:
pgdata: