services: app: # A published image, pinned to an exact version by the installer, never a # moving tag. The default here is only what an unconfigured `docker # compose up` would get; every real install writes SUPAFFI_VERSION into # .env so an update is a deliberate move from one version to another. # # Building from source instead is still supported and is one line in .env: # COMPOSE_FILE=docker-compose.yml:docker-compose.build.yml image: ghcr.io/ugolbck/supaffi:${SUPAFFI_VERSION:-latest} restart: unless-stopped environment: DATABASE_URL: postgres://supaffi:${POSTGRES_PASSWORD}@db:5432/supaffi MASTER_ENCRYPTION_KEY: ${MASTER_ENCRYPTION_KEY} # decrypts Merchant Stripe keys at rest (ADR 0003), generated once at install, never stored in the DB AUTH_SECRET: ${AUTH_SECRET} # signs Owner session JWTs (Auth.js) AUTH_TRUST_HOST: "true" # Instance is served on many Merchant domains (ADR 0006), so there is no single AUTH_URL to pin; Caddy's on_demand_tls ask endpoint already gates which domains reach the app SUPAFFI_DOMAIN: ${SUPAFFI_DOMAIN} # the domain this Instance's own dashboard is served on; a Merchant may not claim it (src/lib/instance.ts) SUPAFFI_HOST_IP: ${SUPAFFI_HOST_IP:-} # the address the startup banner tells the operator to open, when there is no domain SUPAFFI_DASHBOARD_PORT: ${SUPAFFI_DASHBOARD_PORT:-3443} # where caddy-dashboard serves it SUPAFFI_UPDATE_CHECK: ${SUPAFFI_UPDATE_CHECK:-} # off to stop the dashboard reading GitHub's releases page # Where the onboarding check asks for a Merchant domain's certificate. # Fetching the domain's own public address from in here goes out to this # host's IP and back, and many hosts do not route that hairpin, so a # working site reads as unreachable. Set it when your proxy is not the # caddy service below; an instance behind someone else's proxy leaves it # unset and the check falls back to the round trip. SUPAFFI_PROXY_HOST: ${SUPAFFI_PROXY_HOST:-caddy} # SUPAFFI_VERSION is deliberately not passed in. It is baked into the # image at build time, so the version the dashboard reports is the # version that is actually running rather than whatever .env claims. ports: # Loopback by default, which is what proxy-less mode needs: the # operator's own reverse proxy forwards to 127.0.0.1:3000, and the SSH # tunnel in the README works with no extra steps. Binding all # interfaces would be worse than it looks, for the reason spelled out # on the db service below: Docker's iptables rules land ahead of ufw # and firewalld, so a published port is internet-reachable on a host # the operator believes is firewalled. Override deliberately, never by # accident. - "${SUPAFFI_APP_BIND:-127.0.0.1:3000}:3000" depends_on: db: condition: service_healthy db: image: postgres:18-alpine restart: unless-stopped # No published port. Docker writes its own iptables rules ahead of ufw and # firewalld, so a published 5432 is reachable from the internet even on a # host the operator believes is firewalled. The app reaches it as db:5432 # over the compose network. For local psql access see # docker-compose.override.yml.example. environment: POSTGRES_USER: supaffi POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: supaffi healthcheck: # The app's entrypoint runs `prisma migrate deploy` before the server # starts. Waiting only for the container to exist (plain depends_on) # lets that run before Postgres accepts connections; it usually wins the # race, which is the worst kind of bug to ship to strangers' machines. test: ["CMD-SHELL", "pg_isready -U supaffi -d supaffi"] interval: 3s timeout: 3s retries: 20 volumes: - supaffi_db:/var/lib/postgresql caddy: image: caddy:2-alpine restart: unless-stopped # Opt-in, because this service takes ports 80 and 443 for itself and a # server that already runs a reverse proxy has neither to give. The # installer writes COMPOSE_PROFILES=bundled-proxy into .env for the # default path; leaving it unset drops this service entirely and the # operator's own proxy forwards to SUPAFFI_APP_BIND instead. # # A profile rather than a second compose file selected with -f: the # choice then lives in .env, so every command afterwards stays a plain # `docker compose up -d` / `docker compose logs app`, which is what the # README and the installer's token grep both rely on. profiles: ["bundled-proxy"] environment: # Compose always materializes this key inside the container, even when # SUPAFFI_DOMAIN is unset on the host, so Caddy would otherwise see an # empty string rather than a missing variable. The Caddyfile's # {$SUPAFFI_DOMAIN:localhost} default only fires on a genuinely absent # variable, so a present-but-empty value skips it and crashes Caddy on # startup. The default belongs here too, not just in the Caddyfile. SUPAFFI_DOMAIN: ${SUPAFFI_DOMAIN:-localhost} # substituted into the named site block in ./Caddyfile before parsing ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - supaffi_caddy_data:/data depends_on: - app caddy-dashboard: image: caddy:2-alpine restart: unless-stopped # Always runs, unlike the caddy service above. That one only exists when # Supaffi owns ports 80 and 443, and the dashboard has to be reachable on # a server where the operator's own nginx owns them. Compose also rejects # an empty port entry, so one service cannot publish 80/443 in one # situation and 3443 in another; two port layouts need two services. # # Still behind a profile so a single COMPOSE_PROFILES value in .env # describes the whole shape of the install. The installer always writes # dashboard-tls into it. profiles: ["dashboard-tls"] environment: # Defaults repeated here and not left to the Caddyfile alone: Compose # materializes every key it declares, so an unset host variable would # reach Caddy as an empty string, which is not the same as absent and # skips the Caddyfile's own default. That crash-looped the other Caddy # once already. SUPAFFI_HOST_IP: ${SUPAFFI_HOST_IP:-127.0.0.1} SUPAFFI_DASHBOARD_PORT: ${SUPAFFI_DASHBOARD_PORT:-3443} ports: # On all interfaces, deliberately, unlike the app's own binding. Being # reachable from the operator's laptop with no DNS and no proxy is the # entire point of this service, and the connection is encrypted. - "${SUPAFFI_DASHBOARD_PORT:-3443}:${SUPAFFI_DASHBOARD_PORT:-3443}" volumes: - ./Caddyfile.dashboard:/etc/caddy/Caddyfile - supaffi_caddy_dashboard_data:/data depends_on: - app volumes: supaffi_db: supaffi_caddy_data: supaffi_caddy_dashboard_data: