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; commit for workspace snapshots; writes via POST;
  # system for the disk-usage read).
  # Everything else — AUTH, SECRETS, SWARM, 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:
    # bump version and digest together, and keep docker-compose.dev.yml in sync.
    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.
      - COMMIT=1       # POST /commit only: turning a workspace container into an
                       # image is how a snapshot captures the writable layer
                       # (/home/dev, sudo apt installs). It is its own section in
                       # this proxy, not part of CONTAINERS, so it has to be named
                       # here. What it grants is narrow: read one container, write
                       # one image. It cannot create, start or exec anything.
      - CONTAINERS=1   # create/start/stop/restart/update/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
      - SYSTEM=1       # GET /system/df only: the disk-usage figures the admin
                       # System page reports. The Engine API has no writable
                       # /system endpoint, so this grants a read and nothing
                       # else; the per-type prunes sit under the sections above.
    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.
    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.
  #
  # It also closes both control-plane volumes to everyone but root. Remap moves
  # Docker's whole data root under the workspaces' range, so these volumes sit
  # beside every workspace's layers, and a process that escaped a workspace
  # filesystem-wise keeps a uid from that range. With the default modes it could
  # read the database (0644) and, because Docker hands a fresh volume directory
  # to the remapped root, replace or delete acme.json. Owned 0:0 and 0700, the
  # range reaches neither. Every control-plane volume belongs on this list, and
  # test/installer/control_plane_volumes_test.sh fails if one is missing.
  # docs/plans/WORKSPACE_UID_ISOLATION_PLAN.md has the measurements.
  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,
    # and is how an install that predates the modes picks them up.
    restart: "no"
    # chown first: chmod needs no capability only once uid 0 owns the file.
    # `go=` rather than a fixed mode, so the files keep their owner bits and an
    # existing 0644 database loses its group and world read along with the
    # directory.
    command: ["/bin/sh", "-c", "chown -R 0:0 /var/lib/devgrail /acme && chmod -R go= /var/lib/devgrail /acme"]
    volumes:
      - devgrail-data:/var/lib/devgrail
      - traefik-acme:/acme
    # 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. Traefik runs
    # with host ids too, as real root, so a 0700 root-owned /acme costs it nothing.
    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: a fresh volume root is 0755, so it is
    # traversable already, and after the first run uid 0 owns everything here.
    # chmod needs nothing beyond ownership. 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
      # Start-order again, for the startup reconcile (internal/docker, Reconcile):
      # it reattaches Traefik to every workspace network, because a recreated
      # Traefik joins only the networks declared here. Run before the new Traefik
      # exists, it finds none and every terminal and app route of a running
      # workspace answers 502 until that workspace restarts. restart: true
      # restarts the server whenever Compose recreates Traefik alone, so the
      # reconcile runs then too.
      traefik:
        condition: service_started
        restart: true
    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}
      # The same flag Traefik's allowACMEByPass reads below, so the server can
      # tell a workspace that claims a nested domain whether its own proxy can
      # get certificates. Reported only; it changes nothing the server does.
      - DEVGRAIL_ACME_BYPASS=${DEVGRAIL_ACME_BYPASS:-false}
      # The AppArmor profile workspaces run under. install.sh loads
      # devgrail-workspace into the kernel and sets this only once that worked;
      # empty (a host without AppArmor) keeps Docker's docker-default.
      - DEVGRAIL_WORKSPACE_APPARMOR_PROFILE=${DEVGRAIL_WORKSPACE_APPARMOR_PROFILE:-}
      # The profile Docker-enabled workspaces run under, devgrail-workspace-nested.
      # install.sh sets it only when the kernel holds it and the host is ready
      # (userns-remap on, /dev/fuse present); empty means the mode is not offered.
      - DEVGRAIL_WORKSPACE_NESTED_APPARMOR_PROFILE=${DEVGRAIL_WORKSPACE_NESTED_APPARMOR_PROFILE:-}
      # 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 dashboard files upgrade requests and reads their outcome.
      # Mounted below; see the volumes comment for why one is read-only.
      - DEVGRAIL_BACKUP_ARCHIVE_DIR=/backups/archives
      - DEVGRAIL_BACKUP_REQUEST_DIR=/backups/request
      - DEVGRAIL_BACKUP_STATUS_DIR=/backups/status
      - DEVGRAIL_UPGRADE_REQUEST_DIR=/upgrade/request
      - DEVGRAIL_UPGRADE_STATUS_DIR=/upgrade/status
      - DEVGRAIL_CERTIFICATES_REQUEST_DIR=/certificates/request
      - DEVGRAIL_CERTIFICATES_STATUS_DIR=/certificates/status
      # 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
      # Upgrades from the dashboard (install.sh, "upgrade helper"). The server
      # may only leave a one-line request in the first; a root-owned helper on
      # the host verifies and carries it out, and reports back in the second.
      # The status mount must stay :ro — the server runs as host uid 0, so the
      # mount flag is the only thing stopping it planting a symlink there for
      # the root helper to write through. docs/SECURITY.md has the full list.
      # Full deployment archives are sensitive. Only the host helper may mutate
      # them; HTTP download requires a fresh password and an admin session.
      - ${DEVGRAIL_BACKUP_ARCHIVES_HOST:-../backups}:/backups/archives:ro
      - ./backups-request:/backups/request
      - ./backups-status:/backups/status:ro
      - ./upgrade-request:/upgrade/request
      - ./upgrade-status:/upgrade/status:ro
      # Same trust boundary as upgrades; never mount deploy/.env into the server.
      - ./certificates-request:/certificates/request
      - ./certificates-status:/certificates/status:ro
    # 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.
    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's own Docker interface, and a read-only one. Traefik uses the Docker
  # API for exactly one thing — reading container labels to build routes — and a
  # GET is all that needs. Mounting the raw socket for that made a Traefik
  # compromise a host compromise: the socket *is* the daemon, and the `:ro` on a
  # socket bind constrains writes to the file, not the API reachable through it.
  # This proxy allows the CONTAINERS section and nothing else, and leaves POST
  # unset, so every write method is refused with 403 at the gateway.
  #
  # Deliberately a second proxy rather than a share of docker-socket-proxy above:
  # that one must allow writes, because devgrail-server creates and execs into
  # workspaces through it. Pointing Traefik at it would hand Traefik those verbs
  # too, which is the whole thing being removed here.
  traefik-docker-proxy:
    # Same pinned version + digest as docker-socket-proxy above, which carries
    # the reasoning; keep the two refs in step.
    image: tecnativa/docker-socket-proxy:v0.4.2@sha256:1f3a6f303320723d199d2316a3e82b2e2685d86c275d5e3deeaf182573b47476
    container_name: devgrail-traefik-docker-proxy
    restart: unless-stopped
    environment:
      # The entire allowance. Traefik's Docker provider lists and inspects
      # containers (CONTAINERS), follows /events for changes, and negotiates the
      # API version over /_ping and /version. The Swarm discovery that once
      # needed NETWORKS/SERVICES/TASKS is a separate provider in Traefik v3 and
      # is not enabled. The last three default to 1 in this image; they are
      # written out because this list is the security boundary, and a boundary
      # should not be half-inherited from an image's defaults.
      - CONTAINERS=1
      - EVENTS=1
      - PING=1
      - VERSION=1
      # POST is absent, and its absence is what makes this read-only: without it
      # the proxy refuses POST/PUT/DELETE outright. Adding it here to clear some
      # future error would silently grant Traefik container creation — if a
      # Traefik upgrade ever needs a write, that is a decision, not a config fix.
    volumes:
      # Read-only mount: the proxy reads the socket; it does not write to it.
      - /var/run/docker.sock:/var/run/docker.sock:ro
    # Same reason as docker-socket-proxy: the host socket is root:docker, which a
    # userns-remapped container cannot open. Only workspaces are remapped.
    userns_mode: "host"
    security_opt:
      - no-new-privileges:true
    networks:
      - devgrail-traefik-docker

  traefik:
    # Pinned to a specific patch, not the floating v3 tag — Traefik changes
    # provider/flag behavior across minors. Upgrade deliberately, and keep
    # docker-compose.dev.yml in sync.
    #
    # 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}
    # Start-order only, not readiness: like docker-socket-proxy, the pinned proxy
    # image declares no HEALTHCHECK, so service_healthy would never be satisfied.
    # Traefik retries a provider connection on its own, so losing this race costs
    # a log line and a few seconds without container routes, not a failed start.
    depends_on:
      traefik-docker-proxy:
        condition: service_started
      # Closes the ACME store to the workspaces' uid range (see
      # devgrail-data-init) before Traefik writes to it.
      devgrail-data-init:
        condition: service_completed_successfully
    command:
      - --providers.docker=true
      # Discovery goes through the read-only gateway above, not /var/run/docker.sock.
      # Traefik reads labels and writes nothing, so it gets an interface that can
      # only read. Changing this back to the socket re-opens S6 in
      # docs/analyses/SECURITY_ANALYSIS_2026-09-06.md.
      - --providers.docker.endpoint=tcp://traefik-docker-proxy:2375
      - --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
      # Traefik v3 defaults respondingTimeouts.readTimeout to 60s — the max time
      # to read the *entire request including body*. A dashboard upload is one
      # request carrying the whole file, base64'd, and the admin-set upload limit
      # goes up to 256 MiB: over a normal uplink that takes minutes and was cut
      # at 60s. Raise it to 10min; 0 would disable it entirely, leaving a stalled
      # client holding its connection forever. devgrail-server still bounds the
      # headers itself (ReadHeaderTimeout) and every body by size (bodylimit.go).
      # writeTimeout stays 0 (unbounded downloads). Matches docker-compose.dev.yml.
      - --entrypoints.web.transport.respondingTimeouts.readTimeout=600s
      - --entrypoints.websecure.transport.respondingTimeouts.readTimeout=600s
      # 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 the Traefik that owns the hostname on :443.
      #
      # By default this Traefik answers every acme-tls/1 handshake itself, at the
      # TLS mux, before any TCP passthrough router sees it. That breaks a second
      # DevGrail behind this one — one running inside a workspace, reached
      # through a passthrough admin route such as *.inner.example.com — because
      # its challenges never reach it and its issuance fails.
      # DEVGRAIL_ACME_BYPASS=true (install.sh --acme-bypass=yes) lets a TCP
      # router whose HostSNI matches the challenge take it instead. Hostnames no
      # TCP router claims, this install's own among them, still get their
      # challenges answered here. Off by default: a host with no nested DevGrail
      # gains nothing from it. Static configuration, so changing it recreates
      # Traefik. docs/RUNBOOK.md has the nested setup.
      - --entrypoints.websecure.allowACMEByPass=${DEVGRAIL_ACME_BYPASS:-false}
      - --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:
      # No raw docker.sock mount — label discovery is brokered by traefik-docker-proxy.
      # 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, so
      # the mount is read-only — the writer is devgrail-server, above. One
      # consequence to know about: Docker can no longer create the nested
      # system.yml mountpoint inside a read-only mount (it fails the container
      # with EROFS at start), so install.sh and deploy.sh create that empty stub
      # before `compose up`.
      - ./dynamic:/dynamic:ro
      # 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. No longer about the Docker socket —
    # that is brokered now — but about the ACME store: acme.json is 0600 and owned
    # by uid 0 on every install to date, and a remapped Traefik (whose root is
    # 100000) could not open it, silently losing every issued certificate on
    # upgrade. Only untrusted workspace containers are remapped.
    userns_mode: "host"
    # Edge (reach workspaces + serve them the internal MCP entrypoint), control
    # (reach devgrail-server) and its own read-only Docker gateway. Traefik is the
    # only bridge between edge and control.
    networks:
      - devgrail
      - devgrail-control
      - devgrail-traefik-docker
    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
  # Traefik ↔ traefik-docker-proxy only, and separate from devgrail-docker on
  # purpose: if the writable proxy and the read-only one shared a network,
  # Traefik could simply address the writable one and the split would buy nothing.
  devgrail-traefik-docker:
    internal: true

# Control-plane state. Under userns-remap these sit in the same data root as the
# workspaces' layers and volumes, so each one must be closed to the remapped
# range: mount it in devgrail-data-init, which makes it 0:0 and 0700 on every
# start. test/installer/control_plane_volumes_test.sh checks that none is missed.
volumes:
  traefik-acme:
  devgrail-data:
