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

  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
    environment:
      - DEVGRAIL_CONFIG=/etc/devgrail/config.yaml
      - DEVGRAIL_DOMAIN=${DEVGRAIL_DOMAIN}
      - DEVGRAIL_BASE_DOMAIN=${DEVGRAIL_BASE_DOMAIN}
      # First-boot seed for the admin password. It is NOT re-applied on later
      # restarts, so it can be blanked in .env once you have logged in; set
      # DEVGRAIL_FORCE_ADMIN_PASSWORD=1 for one boot to reset a forgotten one.
      - 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).
      - DEVGRAIL_TRAEFIK_DYNAMIC_PATH=/dynamic/managed.yml
    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".
    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.
      - --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}
      - --certificatesresolvers.letsencrypt.acme.storage=/acme/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.
      # 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:
