services:
  # Least-privilege Docker API gateway. The server holds host-root power through
  # the Docker socket, so instead of mounting the raw socket we front it with this
  # proxy and allow only the API sections DevGrail actually uses (containers, exec,
  # images, networks, volumes; writes via POST). Everything else — AUTH, SECRETS,
  # SWARM, SYSTEM, the daemon socket itself — stays denied by default. The proxy
  # lives on an internal-only network so nothing but devgrail-server can reach it.
  docker-socket-proxy:
    # Pinned by version + digest, not :latest — this fronts the raw Docker socket
    # under userns_mode: host, so an upstream release must not change its env-var
    # allowlist semantics under us. Upgrading is a deliberate maintainer edit; see
    # DEVGRAIL_SYSTEM_SPEC.md §9 "Third-party image pins".
    image: tecnativa/docker-socket-proxy:v0.4.2@sha256:1f3a6f303320723d199d2316a3e82b2e2685d86c275d5e3deeaf182573b47476
    container_name: devgrail-docker-proxy
    restart: unless-stopped
    environment:
      # Allow write methods (POST/PUT/DELETE). Without this the proxy is read-only.
      - POST=1
      # API sections DevGrail uses (see internal/docker/*.go). All default to 0.
      - CONTAINERS=1   # create/start/stop/restart/remove/inspect/logs/exec-create
      - EXEC=1         # exec attach/inspect (terminal, file ops, script runs)
      - IMAGES=1       # image inspect + pull
      - NETWORKS=1     # network list/create/remove + connect (per-workspace networks)
      - VOLUMES=1      # named workspace volumes: create/list/inspect/remove
    volumes:
      # Read-only mount: the proxy reads the socket; it does not write to it.
      - /var/run/docker.sock:/var/run/docker.sock:ro
    # Opt out of daemon-level userns-remap: this container mounts the raw Docker
    # socket (owned by root:docker on the host), which a remapped container
    # cannot access. Only untrusted workspace containers are remapped; trusted
    # infra runs with host ID mappings. See DEVGRAIL_SYSTEM_SPEC.md §5.
    userns_mode: "host"
    security_opt:
      - no-new-privileges:true
    networks:
      - devgrail-docker

  # One-shot ownership fixup for the data volume, before devgrail-server starts.
  # With daemon-level userns-remap on, Docker creates new named-volume directories
  # owned by the *remapped* root (e.g. 100000:100000). devgrail-server runs
  # userns_mode: host (so it is real uid 0) with cap_drop: ALL — and root without
  # CAP_DAC_OVERRIDE gets no permission bypass, so it cannot write a directory it
  # does not own. SQLite then fails every open with "unable to open database file
  # (14)" and the server crash-loops forever. Chowning the volume to 0:0 matches
  # the ids devgrail-server actually runs as. Idempotent: a no-op on a host
  # without remap, where the directory is already 0:0.
  devgrail-data-init:
    image: ${DEVGRAIL_SERVER_IMAGE:-devgrail-server}:${TAG:-latest}
    container_name: devgrail-data-init
    # One-shot: exits 0 and stays exited. `compose up` re-runs it on every start,
    # which is what keeps the fixup self-healing rather than install-time-only.
    restart: "no"
    command: ["/bin/sh", "-c", "chown -R 0:0 /var/lib/devgrail"]
    volumes:
      - devgrail-data:/var/lib/devgrail
    # Must match devgrail-server: the point is to hand the volume to the ids that
    # service runs as, so both have to resolve uid 0 the same way.
    userns_mode: "host"
    read_only: true
    # No network at all — it chowns a directory and exits. This also keeps Compose
    # from creating a default network just for it.
    network_mode: "none"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    # CHOWN is the one capability chown(2) needs to change an owner you are not.
    # Deliberately not DAC_OVERRIDE: the volume root is 0755, so it is traversable
    # already, and this container should not be able to bypass permissions.
    cap_add:
      - CHOWN

  devgrail-server:
    # Production runs a pre-built image loaded from a tarball (install.sh does
    # `docker load`), not a local build — the VPS has no repo checkout. For a
    # local prod-like build, ./deploy.sh builds devgrail-server:latest first.
    # Local dev uses `make dev` (host-run binary), not this compose.
    image: ${DEVGRAIL_SERVER_IMAGE:-devgrail-server}:${TAG:-latest}
    container_name: devgrail-server
    restart: unless-stopped
    # Start-order only, not readiness: the pinned proxy image defines no
    # HEALTHCHECK, so `condition: service_healthy` here would never be satisfied.
    # The server starts regardless (its Docker client dials lazily, per call) and
    # /healthz reports the docker check as failed until the proxy answers, so
    # install.sh waits on devgrail-server's own probe rather than on container
    # start — which is the readiness signal that actually matters here.
    depends_on:
      docker-socket-proxy:
        condition: service_started
      # Gate on the chown finishing, not merely starting — the server opens the
      # database immediately on boot.
      devgrail-data-init:
        condition: service_completed_successfully
    environment:
      - DEVGRAIL_CONFIG=/etc/devgrail/config.yaml
      - DEVGRAIL_DOMAIN=${DEVGRAIL_DOMAIN}
      - DEVGRAIL_BASE_DOMAIN=${DEVGRAIL_BASE_DOMAIN}
      # First-boot seed for the admin account. Neither is re-applied on later
      # restarts (a changed username renames nobody, and the password can be
      # blanked in .env once you have logged in). To reset a forgotten password
      # use `sudo devgrail passwd`, or set DEVGRAIL_FORCE_ADMIN_PASSWORD=1 for
      # one boot.
      - DEVGRAIL_ADMIN_USERNAME=${DEVGRAIL_ADMIN_USERNAME:-admin}
      - DEVGRAIL_ADMIN_PASSWORD=${DEVGRAIL_ADMIN_PASSWORD}
      - DEVGRAIL_FORCE_ADMIN_PASSWORD=${DEVGRAIL_FORCE_ADMIN_PASSWORD:-}
      # Public scheme switch (default on = HTTPS). Drives the scheme in every
      # server-generated URL (OAuth issuer, MCP resource, terminal/app links) and
      # the container Traefik routers; must match the DEVGRAIL_TRAEFIK_FILE above.
      - DEVGRAIL_TLS=${DEVGRAIL_TLS:-on}
      # Talk to Docker through the least-privilege proxy, not the raw socket. The
      # Docker SDK honours DOCKER_HOST via dockerclient.FromEnv (internal/docker).
      - DOCKER_HOST=tcp://docker-socket-proxy:2375
      # Where the Traefik route manager renders managed routes. This sits on the
      # devgrail-dynamic volume, which Traefik's file provider watches (below).
      # Workspace app subdomains render to workspaces.yml beside it, derived from
      # this path. Required: with it unset the server renders neither file and
      # every workspace subdomain silently stops routing, so startup refuses.
      - DEVGRAIL_TRAEFIK_DYNAMIC_PATH=/dynamic/managed.yml
      # Security policy that legitimately differs between a production deployment
      # and a test one — session and OAuth lifetimes, login lockout, HSTS max-age,
      # the always-allowed OAuth redirect URIs. Empty means "not configured", and
      # the server then uses the shipped default for that value, so every line
      # below is inert unless the operator sets it in deploy/.env (documented in
      # .env.example). They are listed explicitly rather than passed through
      # wholesale because this list is the container's entire environment
      # contract; a value not named here does not reach the server.
      - DEVGRAIL_SESSION_TTL=${DEVGRAIL_SESSION_TTL:-}
      - DEVGRAIL_SCOPED_TOKEN_TTL=${DEVGRAIL_SCOPED_TOKEN_TTL:-}
      - DEVGRAIL_LOGIN_MAX_FAILURES=${DEVGRAIL_LOGIN_MAX_FAILURES:-}
      - DEVGRAIL_LOGIN_LOCKOUT=${DEVGRAIL_LOGIN_LOCKOUT:-}
      - DEVGRAIL_OAUTH_AUTH_CODE_TTL=${DEVGRAIL_OAUTH_AUTH_CODE_TTL:-}
      - DEVGRAIL_OAUTH_ACCESS_TOKEN_TTL=${DEVGRAIL_OAUTH_ACCESS_TOKEN_TTL:-}
      - DEVGRAIL_OAUTH_REFRESH_TOKEN_TTL=${DEVGRAIL_OAUTH_REFRESH_TOKEN_TTL:-}
      - DEVGRAIL_OAUTH_EXTRA_REDIRECT_URIS=${DEVGRAIL_OAUTH_EXTRA_REDIRECT_URIS:-}
      - DEVGRAIL_HSTS_MAX_AGE=${DEVGRAIL_HSTS_MAX_AGE:-}
    volumes:
      # No raw docker.sock mount — Docker access is brokered by docker-socket-proxy.
      - /etc/devgrail:/etc/devgrail:ro
      - devgrail-data:/var/lib/devgrail
      # Shared with Traefik: the server writes managed.yml here, Traefik reads it.
      # A host bind mount (not a named volume): on Docker Desktop a named volume
      # shared between the writer (this server) and the reader (Traefik) hits a
      # cross-container coherence bug where Traefik misreads managed.yml and fails
      # to load *all* file-provider routes (404s the whole app). A bind mount goes
      # through the host filesystem and is read coherently by both. managed.yml is
      # derived from the SQLite DB (regenerated on startup), so this holds no state.
      - ./dynamic:/dynamic
    # Container hardening: an RCE/SSRF here is the highest-value target (it drives
    # the Docker proxy), so shrink the blast radius. Read-only rootfs with a tmpfs
    # for scratch; SQLite + data live on the writable devgrail-data volume.
    read_only: true
    tmpfs:
      - /tmp
    # Opt out of daemon-level userns-remap. The server runs no untrusted code and
    # owns the devgrail-data named volume; running it with host ID mappings keeps
    # that volume's ownership stable and avoids remap churn. Only workspaces are
    # remapped. See DEVGRAIL_SYSTEM_SPEC.md §5.
    userns_mode: "host"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    # Control plane (reach Traefik) + docker proxy. Deliberately NOT on the
    # "devgrail" edge network: workspaces run untrusted code and must not reach the
    # server directly. Traefik bridges edge↔control and is the only path to MCP.
    networks:
      - devgrail-control
      - devgrail-docker

  traefik:
    # Pinned to a specific patch, not the floating v3 tag — Traefik changes
    # provider/flag behavior across minors. Upgrade deliberately; see
    # DEVGRAIL_SYSTEM_SPEC.md §9 "Third-party image pins".
    #
    # TRAEFIK_IMAGE overrides the whole reference, pin included, for a deployment
    # that pulls from a private mirror or an air-gapped registry. It is an escape
    # hatch for *where* the image comes from; changing which version it names is
    # the operator taking the pin's guarantees on themselves.
    image: ${TRAEFIK_IMAGE:-traefik:v3.7.6}
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      # Fallback network for workspaces created before per-workspace network
      # isolation; those containers carry no traefik.docker.network label and
      # still live on the shared bridge. Newer containers set the label
      # explicitly and override this. Keep it until no legacy workspace remains.
      - --providers.docker.network=devgrail
      # Enable the API + dashboard. NOT insecure mode: the dashboard is exposed
      # only through the gated /traefik + /api routers in dynamic.yml,
      # behind the devgrail-admin-forwardauth middleware (admin session required).
      - --api=true
      - --api.dashboard=true
      # Directory provider so both the static system config (system.yml) and the
      # DevGrail-managed routes (managed.yml, written by devgrail-server) are loaded
      # and hot-reloaded. A file provider can watch a directory or a single file,
      # not both — hence the directory.
      - --providers.file.directory=/dynamic
      - --providers.file.watch=true
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      # Internal-only MCP entrypoint. NOT published to the host below, so it is
      # reachable only by containers on the "devgrail" edge network (workspaces).
      # Its router does not stamp X-DevGrail-Forwarded, so in-container agents are
      # treated as internal — preserving the internal-API-key path without giving
      # workspaces a direct route to devgrail-server. The port is mirrored by
      # internal/config.InternalMCPPort, which builds the URL agents connect to.
      - --entrypoints.mcpinternal.address=:9100
      # TLS-ALPN-01 (not HTTP-01): the challenge is answered over the TLS
      # handshake on :443 (SNI + the acme-tls/1 ALPN). This requires the ACME
      # server to reach THIS Traefik directly on :443. It does NOT work behind
      # another ACME-enabled Traefik (e.g. a second DevGrail fronting this one):
      # that Traefik's own acme-tls/1 handler intercepts the challenge handshake
      # at the TLS mux before any TCP passthrough router can forward it by SNI, so
      # issuance fails. DevGrail runs on a single VPS, not behind another DevGrail.
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
      # Which ACME CA to order from. Defaults to Let's Encrypt production; set
      # DEVGRAIL_ACME_CASERVER (install.sh --acme-ca=staging) to point at the
      # staging CA instead. Staging issues untrusted certificates but has no
      # meaningful rate limit, which is what makes repeated install/uninstall
      # testing against one hostname possible: production allows only 5 certs
      # per exact identifier set per 168h, and a purged traefik-acme volume
      # burns one on every reinstall.
      - --certificatesresolvers.letsencrypt.acme.caserver=${DEVGRAIL_ACME_CASERVER:-https://acme-v02.api.letsencrypt.org/directory}
      # Storage is per-CA, not shared: acme.json holds the registered ACME
      # account, and an account from one CA is meaningless to another — pointing
      # a staging run at a production store makes every order fail with
      # "account does not exist". install.sh derives the filename from the CA
      # (acme.json for production, acme-staging.json for staging), so switching
      # back and forth leaves each store, and its certificates, intact.
      - --certificatesresolvers.letsencrypt.acme.storage=/acme/${DEVGRAIL_ACME_STORAGE:-acme.json}
    # Real :80/:443 by default — the ACME TLS-ALPN-01 challenge requires the ACME
    # server to reach the domain on :443; :80 stays published for the HTTP→HTTPS
    # redirect. Override HTTP_PORT/HTTPS_PORT only for a local prod-like test
    # where those ports are taken.
    ports:
      - "${HTTP_PORT:-80}:80"
      - "${HTTPS_PORT:-443}:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # The static system config and the managed-routes file share /dynamic via a
      # host bind mount (see devgrail-server above for why this is a bind mount and
      # not a named volume). The static system config is bind-mounted nested inside
      # it (version-controlled, read-only); the server writes managed.yml alongside,
      # plus workspaces.yml for per-workspace app subdomains. Those are two files
      # rather than one because the file provider fails closed per file: a bad
      # workspace route must not take down the dashboard, /mcp and forwardauth.
      # Traefik's file provider only reads these files; it never writes here.
      - ./dynamic:/dynamic
      # TLS by default; DEVGRAIL_TRAEFIK_FILE=dynamic.http.yml selects the
      # plain-HTTP router set when DEVGRAIL_TLS=off (set by install.sh/deploy.sh).
      - ./traefik/${DEVGRAIL_TRAEFIK_FILE:-dynamic.yml}:/dynamic/system.yml:ro
      - traefik-acme:/acme
    # Lets managed "host" routes (http://host.docker.internal:<port>) reach
    # services listening on the VPS host on any Docker engine.
    extra_hosts:
      - "host.docker.internal:host-gateway"
    # Opt out of daemon-level userns-remap: Traefik mounts the raw Docker socket
    # for label discovery, which a remapped container cannot read. Only untrusted
    # workspace containers are remapped. See DEVGRAIL_SYSTEM_SPEC.md §5.
    userns_mode: "host"
    # Edge (reach workspaces + serve them the internal MCP entrypoint) and
    # control (reach devgrail-server). Traefik is the only bridge between them.
    networks:
      - devgrail
      - devgrail-control
    restart: unless-stopped

networks:
  # Edge network: Traefik + workspace containers. Created out-of-band (deploy.sh)
  # and shared with spawned containers, hence external.
  devgrail:
    external: true
  # Control network: Traefik + devgrail-server only. Managed by this compose stack.
  devgrail-control:
  # Docker-proxy network: devgrail-server ↔ docker-socket-proxy only. internal:true
  # means no gateway to the outside world — the proxy is never externally routable.
  devgrail-docker:
    internal: true

volumes:
  traefik-acme:
  devgrail-data:
