# Local API Proxy -- Docker Compose deployment. # # No top-level `version:` key: it is obsolete in Compose v2+ and now warns. # # Every setting comes from .env. Copy .env.example to .env and fill it in: # # cp .env.example .env # # edit .env and fill in the two keys # docker compose up -d # # Two independent layers refuse to start on a missing value. Compose itself # fails first, via the required-variable interpolation form below, before an # image is built or a container is created. If a value slips past that, # proxy.mjs validates types and ranges at startup and exits 1 naming every # problem at once. # # Note the ":?" form (not "?"): it rejects an empty value as well as an unset # one. That matters because Compose expands an undefined variable to an empty # string with only a warning, and an empty PORT would otherwise become 0 and # bind a random port. name: local-api-proxy services: proxy: build: context: . dockerfile: Dockerfile image: local-api-proxy:4.1.0 restart: unless-stopped # Insurance rather than a fix: proxy.mjs handles SIGTERM itself and the # Dockerfile uses exec form, so signals already arrive correctly. init:true # keeps that true if a wrapper script is ever introduced. init: true # proxy.mjs caps its own graceful shutdown at 5 seconds, so a longer grace # period buys nothing. A stop during an active SSE stream records exit 1. stop_grace_period: 10s env_file: - .env environment: # HOST is deliberately NOT passed through from .env. # # Inside a container, 127.0.0.1 is the container's own loopback namespace. # Binding it would make the listener unreachable from the host: Docker's # port publisher connects to the container's bridge IP, so `ports:` below # would map to nothing and every request would be refused while the # container looked perfectly healthy and logged that it was listening. # 0.0.0.0 is the only correct bind address in a container, so it is fixed # here where it cannot be got wrong. `environment:` takes precedence over # `env_file:`, so this wins even if HOST is set in .env. # # This is not a public exposure. 0.0.0.0 means every interface the # CONTAINER has, which on a private Docker network is one address nobody # outside that network can dial. What is actually reachable is decided # below and in front: `ports:` publishes to host loopback only, and a # reverse proxy on the Docker network dials PORT directly (see # `expose:`). This line widens neither of those. HOST: "0.0.0.0" # Everything else is passed through and must be present in .env. PORT: "${PORT:?missing in .env - see .env.example}" HEALTH_PORT: "${HEALTH_PORT:?missing in .env - see .env.example}" ALLOWED_HOSTS: "${ALLOWED_HOSTS:?missing in .env - see .env.example}" UPSTREAM_BASE_URL: "${UPSTREAM_BASE_URL:?missing in .env - see .env.example}" UPSTREAM_API_KEY: "${UPSTREAM_API_KEY:?missing in .env - see .env.example}" LOCAL_PROXY_KEY: "${LOCAL_PROXY_KEY:?missing in .env - see .env.example}" CLAUDE_CODE_VERSION: "${CLAUDE_CODE_VERSION:?missing in .env - see .env.example}" OVERRIDE_UPSTREAM_USER_AGENT: "${OVERRIDE_UPSTREAM_USER_AGENT:?missing in .env - see .env.example}" UPSTREAM_MODEL: "${UPSTREAM_MODEL:?missing in .env - see .env.example}" UPSTREAM_TIMEOUT_MS: "${UPSTREAM_TIMEOUT_MS:?missing in .env - see .env.example}" RETRY_AFTER_SECONDS: "${RETRY_AFTER_SECONDS:?missing in .env - see .env.example}" MAX_BODY_BYTES: "${MAX_BODY_BYTES:?missing in .env - see .env.example}" PROXY_LOG: "${PROXY_LOG:?missing in .env - see .env.example}" PROXY_LOG_VERBOSE: "${PROXY_LOG_VERBOSE:?missing in .env - see .env.example}" PROXY_LOG_BODY_LIMIT: "${PROXY_LOG_BODY_LIMIT:?missing in .env - see .env.example}" PROXY_TRACE_FILE: "${PROXY_TRACE_FILE:?missing in .env - see .env.example}" PROXY_TRACE: "${PROXY_TRACE:?missing in .env - see .env.example}" PROXY_TRACE_BODY_LIMIT: "${PROXY_TRACE_BODY_LIMIT:?missing in .env - see .env.example}" # How a reverse proxy in front of this service reaches it. Traefik (under # Dokploy, or any other router sharing the Docker network) connects to the # container's own IP on PORT over that network -- it does not go out to the # host and back in through a published port, which is why `ports:` below # can stay pinned to loopback without cutting off remote clients. # # `expose:` publishes nothing and opens no firewall hole; container-to- # container traffic on a user-defined bridge works with or without it. It # is here as the declaration of which port is the routable one, so the # answer is in the file rather than in someone's memory. # # HEALTH_PORT is deliberately absent. It is bound to the container's own # loopback and is not meant to be routable even from the Docker network. expose: - "${PORT:?missing in .env - see .env.example}" ports: # The "127.0.0.1:" prefix is the important part. This service holds a # paid upstream credential, so it must not be reachable from the LAN. A # bare "18989:18989" would be: Docker writes its publish rules into the # DOCKER iptables chain, which is traversed BEFORE ufw or firewalld, so # a deny-all host firewall does not protect a bare publish. # # HOST_PORT is the port on your machine; PORT is the port inside the # container. They can differ if 18989 is already taken locally. - "127.0.0.1:${HOST_PORT:?missing in .env - see .env.example}:${PORT:?missing in .env - see .env.example}" healthcheck: # The image is node:alpine, which has neither curl nor wget available as # a dependable tool, so node itself makes the request -- it is guaranteed # present and global fetch exists on this Node version. # # HEALTH_PORT, not PORT. /health moved off the public port and is now # served only on the container's internal loopback at HEALTH_PORT, so # this is the sole address that answers it. That the probe still works is # the point of the split: this command runs INSIDE the container, which # is exactly the one place that listener is reachable from. # # 127.0.0.1, never $HOST. HOST is 0.0.0.0, which is a bind address, not # a connect address. # # The bare "/health" no longer needs defending. This file used to warn # against a query string, because the trace bypass was an exact string # match on req.url and "/health?probe=1" would be traced on every # interval. The health listener is its own server and never traces, so # that hazard is gone; the URL stays bare because nothing needs adding # to it. # # No shell-style interpolation braces in the command: Compose would # substitute them as one of its own variables, so the port is read at # run time via process.env.HEALTH_PORT instead. # # This is a liveness probe only. `ok` is hardcoded true and the handler # never contacts the gateway, so a healthy container proves the event # loop responds -- NOT that the gateway is reachable or the key is valid. test: - CMD - node - "-e" - "fetch('http://127.0.0.1:'+process.env.HEALTH_PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" interval: 30s timeout: 5s retries: 3 start_period: 5s # Hardening. The proxy writes nothing except the optional trace file, and # both PORT and HEALTH_PORT are above 1024, so it needs no capabilities and # no writable rootfs. read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true # The only writable path, and the only place PROXY_TRACE_FILE may point. # Deliberately a tmpfs and not a volume: the trace contains full prompts # and completions, so it is capped at 64 MiB and dies with the container # instead of accumulating on the host. Read a live trace with # docker compose exec proxy cat /tmp/proxy-trace.log tmpfs: - /tmp:size=64m,mode=1777 # PROXY_LOG writes to stdout and bodies can be large, so bound the log. logging: driver: json-file options: max-size: "10m" max-file: "3"