# ============================================================================= # ProjectAchilles — Public / on-prem single-server stack # ============================================================================= # Purpose-built compose file for a publicly-reachable OR on-prem install behind # a Caddy reverse proxy that terminates TLS. Unlike docker-compose.yml (which # binds app ports to 127.0.0.1 for local dev), here ONLY Caddy is exposed to the # network; backend, frontend, and Elasticsearch stay on the internal compose # network. # # Driven by scripts/deploy-server.sh, which generates the root .env and renders # deploy/caddy/rendered/{tls,global,sites-extra}.caddy (gitignored). Run # ./scripts/deploy-server.sh first (and again after every `git pull`, so the # rendered snippets match the checkout); after that you can also run compose # directly: # # docker compose -f docker-compose.server.yml up -d --build # docker compose -f docker-compose.server.yml --profile elasticsearch up -d --build # # All ${VARS} below are read from the root .env file written by the deploy script. # ============================================================================= services: caddy: image: ${CADDY_IMAGE:-caddy:2.10.0-alpine} restart: unless-stopped # CADDY_BIND_IP pins the published ports to one host address (the LAN IP # for an on-prem install). Unset or empty = no host IP at all, so Docker # publishes on every IPv4 AND IPv6 address exactly as before (an explicit # 0.0.0.0 would drop the [::] listeners). Bracket an IPv6 address: [fd00::1]. ports: - "${CADDY_BIND_IP:+${CADDY_BIND_IP}:}80:80" - "${CADDY_BIND_IP:+${CADDY_BIND_IP}:}443:443" - "${CADDY_BIND_IP:+${CADDY_BIND_IP}:}443:443/udp" # HTTP/3 environment: - ACHILLES_DOMAIN=${ACHILLES_DOMAIN:?set ACHILLES_DOMAIN in .env} - ACME_EMAIL=${ACME_EMAIL:-} - CADDY_DNS_PROVIDER=${CADDY_DNS_PROVIDER:-} - CADDY_DNS_TOKEN=${CADDY_DNS_TOKEN:-} volumes: - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./deploy/caddy/rendered:/etc/caddy/rendered:ro - ./deploy/caddy/certs:/etc/caddy/certs:ro # TLS_MODE=achilles-ca: ACHILLES_CA_DIR=/etc/projectachilles/ca. Mount # DIRECTORIES, not files: renewal swaps files by rename, and a file bind # mount would keep serving the old inode. caddy/ holds only root.crt and # the intermediate cert+key; the root KEY is never mounted. public/ is the # root (PEM + DER) served over plain HTTP at /ca/. Other modes mount the # empty committed deploy/caddy/no-ca. - ${ACHILLES_CA_DIR:-./deploy/caddy/no-ca}/caddy:/etc/caddy/ca:ro - ${ACHILLES_CA_DIR:-./deploy/caddy/no-ca}/public:/srv/ca:ro - caddy-data:/data - caddy-config:/config depends_on: frontend: condition: service_started healthcheck: # TCP-level: an HTTP probe from inside the container gets the catch-all # 308 to https://localhost, which has no cert, in every TLS mode. test: ["CMD-SHELL", "nc -z -w 3 127.0.0.1 443 && nc -z -w 3 127.0.0.1 80 || exit 1"] interval: 30s timeout: 5s retries: 3 backend: # The commit label lets scripts/deploy-server.sh reuse images built from # the checked-out commit (a pre-built appliance) instead of rebuilding. build: context: ./backend args: GIT_COMMIT: "${ACHILLES_GIT_COMMIT:-}" labels: io.projectachilles.commit: "${ACHILLES_GIT_COMMIT:-}" restart: unless-stopped expose: - "3000" # All app config (Clerk, secrets, CORS_ORIGIN, AGENT_SERVER_URL, and the # Elasticsearch settings — self-hosted node OR Elastic Cloud) is written to # backend/.env by scripts/deploy-server.sh. Single source of truth. env_file: ./backend/.env environment: - PORT=3000 - NODE_ENV=production - AGENT_SOURCE_PATH=/agent-src # The checkout's .git, read-only: agent builds record the commit they # compiled ("Built from source … at "), so a build made before a # `git pull` shows the old commit. - AGENT_SOURCE_GIT_DIR=/agent-src-git # Keep Go's compiled-object cache in the go-cache volume (Go's default, # /root/.cache/go-build, is lost on every container recreate, so the next # build compiles everything again). Agent builds use it too. - GOCACHE=/root/go/build-cache volumes: - achilles-data:/root/.projectachilles - repo-cache:/app/data - go-cache:/root/go - ./agent:/agent-src:ro - ./.git:/agent-src-git:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] interval: 30s timeout: 5s retries: 3 frontend: build: context: ./frontend labels: io.projectachilles.commit: "${ACHILLES_GIT_COMMIT:-}" restart: unless-stopped expose: - "80" environment: # Same-origin: empty VITE_API_URL makes the SPA call /api on this host, # which nginx proxies to backend:3000 internally. - VITE_API_URL= - CLERK_PUBLISHABLE_KEY=${CLERK_PUBLISHABLE_KEY:-} - VITE_CLERK_PUBLISHABLE_KEY=${VITE_CLERK_PUBLISHABLE_KEY:-${CLERK_PUBLISHABLE_KEY:-}} depends_on: backend: condition: service_healthy # --------------------------------------------------------------------------- # Optional self-hosted Elasticsearch (TLS_ES=self). # Activate with: --profile elasticsearch # Stays on the internal network only — never published to the host/internet. # Use Elastic Cloud instead by setting ELASTICSEARCH_CLOUD_ID/API_KEY in # backend/.env and omitting this profile. # --------------------------------------------------------------------------- elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0 profiles: ["elasticsearch"] restart: unless-stopped environment: - discovery.type=single-node # Security disabled is acceptable here ONLY because ES is not exposed # outside the compose network. Do not publish port 9200. - xpack.security.enabled=false - bootstrap.memory_lock=true - "ES_JAVA_OPTS=-Xms512m -Xmx1024m" ulimits: memlock: soft: -1 hard: -1 expose: - "9200" volumes: - esdata:/usr/share/elasticsearch/data healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] interval: 15s timeout: 10s retries: 10 start_period: 30s deploy: resources: limits: memory: 2G # One-shot seed: creates the index + ~1000 synthetic records, then exits. # Runs only with --profile elasticsearch, and only when SEED_ES=true (set in # the root .env). It is a no-op otherwise, so it is safe to leave enabled. # Compose re-runs it on every `up` (each re-install), so it skips an index # that already holds documents. es-seed: image: python:3.12-slim profiles: ["elasticsearch"] depends_on: elasticsearch: condition: service_healthy volumes: - ./scripts:/scripts:ro entrypoint: > sh -c "test \"${SEED_ES:-false}\" = \"true\" || { echo 'SEED_ES not set — skipping seed'; exit 0; } && pip install -q 'elasticsearch==8.17.2' && python /scripts/generate_synthetic_data.py -c 1000 -o /tmp/seed.ndjson -d 30 && python /scripts/upload_to_elasticsearch.py -f /tmp/seed.ndjson --host http://elasticsearch:9200 --create-index --skip-if-populated" restart: "no" volumes: achilles-data: repo-cache: go-cache: esdata: caddy-data: caddy-config: