Ctrl K
ComunidadGuías…

docker-compose.production.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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: