Skip to main content
Version: 0.1.125 – latest

Deployment reference

This guide describes how to configure and run Repave with Docker Compose in a client or production-like environment without shipping the source code.

Installing or upgrading? Start with the operator runbook. The installer image performs the procedures below — layout, secrets, image pinning, ownership, migrations, verification — in one command.

This document remains the deep reference: every .env variable, backup and restore, nginx and TLS, UAT Docker socket access, and the troubleshooting detail the runbook links into. Reach for it when you need to understand or override what the installer does.

Deployment Model​

The production Compose flow runs:

  • postgres: PostgreSQL 16 with a persistent Docker volume.
  • migrate: a one-shot Prisma migration runner using the released app image.
  • docker-host: a Docker socket proxy sidecar using DOCKER_HOST_IMAGE.
  • app: the prebuilt Repave image from APP_IMAGE.
  • claude-mem-server / claude-mem-worker: the always-on, centralized claude-mem memory server and generation worker (both from CLAUDE_MEM_SERVER_IMAGE), backed by valkey (BullMQ queue) and a claude-mem database created inside the same Postgres instance. Required.
  • cli-proxy-api: the CLIProxyAPI translation gateway sidecar. Every API_KEY/OAUTH-mode agent job — Claude-tier and OpenAI-tier alike — routes through it, with no direct-to-provider fallback for those modes. Disabled by default, opt-in via the cli-proxy-api Compose profile (COMPOSE_PROFILES=cli-proxy-api in .env) — see . A new project now defaults to ANTHROPIC_DIRECT (Claude-only, dispatching straight to the Anthropic API with the project's own key), so a deployment without this sidecar works out of the box; the gateway-routed modes are refused in project Settings while it isn't configured — see . Enable the profile only if a project needs an OpenAI-tier model, or OAuth subscription reuse. The other gateway-bypassing mode is VERTEX .
  • Optional nginx: TLS-terminating reverse proxy in front of app, for deployments exposed directly on a public host, enabled with the nginx Compose profile. Certificates are supplied externally (e.g. host-level certbot); this service does not issue or renew them.

The app image contains the compiled Next.js app, the Repave CLI, Docker CLI client, Docker Compose plugin, Chromium, and app-side runtime tools. It does not include Claude Code; AI agents run in sibling runner containers. It also does not include or run a Docker daemon; Docker operations go through the Compose docker-host sidecar. It does not bundle the optional Graphify CLI in the hardened image; keep Graphify configuration disabled unless you supply a custom app image with a compatible graphify executable.

The standard deployment uses plain postgres:16-alpine3.24 and does not enable WAL-G archiving. The WAL-G backup stack is only used by docker-compose.local.yml for local development and backup experiments.

By default, agents execute in provider-specific sibling containers via AGENT_EXECUTION_MODE=docker. Containerized app deployments do not support local-in-container agent execution.

Files To Deliver​

For a source-code-free client deployment, deliver:

  • docker-compose.yml
  • .env.example
  • this guide
  • the dedicated airgapped install guide when delivering an offline bundle
  • released app, Claude agent runner, claude-mem server, Docker host sidecar, Postgres, Valkey, and CLIProxyAPI gateway sidecar images, either in a registry or as docker save archives
  • the session base images every web IDE session container is built from
  • optional nginx image, and a nginx/conf.d/repave.conf derived from nginx/conf.d/repave.conf.example, when that service will be enabled

Do not deliver the application source tree. docker-compose.build.yml is only for internal or local image builds from source.

If delivering the base airgapped bundle, load these images before starting Compose:

docker load -i server-1.0.0.tar.gz
docker load -i agent-runner-1.0.0.tar.gz
docker load -i claude-mem-server-1.0.0.tar.gz
docker load -i docker-host-1.0.0.tar.gz
docker load -i postgres-1.0.0.tar.gz
docker load -i valkey-1.0.0.tar.gz
docker load -i cli-proxy-api-1.0.0.tar.gz

If delivering the full airgapped bundle, also load:

docker load -i session-base-node-1.0.0.tar.gz
docker load -i session-base-java-1.0.0.tar.gz
docker load -i session-base-dotnet-1.0.0.tar.gz
docker load -i dind-1.0.0.tar.gz

Then set APP_IMAGE, AGENT_RUNNER_IMAGE, DOCKER_HOST_IMAGE, POSTGRES_IMAGE, and CLI_PROXY_IMAGE in .env to the loaded image tags. For fully offline deployment steps, see airgapped-install.md.

Host Requirements​

The host needs:

  • Docker Engine or Docker Desktop
  • Docker Compose v2, available as docker compose
  • Enough disk for Postgres, runtime artifacts, and generated project workspaces
  • Network access to pull the app image and base images used by UAT/Testcontainers, if those features are enabled. Airgapped deployments must preload every required image instead.

Recommended host layout:

/opt/app-rewrite/
docker-compose.yml
.env
data/
workspaces/
cli-proxy-api/
docker/
nginx/ # only when the optional nginx profile is enabled

data and workspaces must be writable by the containers' repave uid (see below). cli-proxy-api/ holds the gateway sidecar's config (config.example.yaml ships with the release; config.yaml is rendered by the deploy/upgrade scripts).

deploy-client-release.sh calls reclaim_ownership_if_needed (in scripts/lib/deploy-helpers.sh) on cli-proxy-api/ before writing into it: when Docker auto-creates a missing bind-mount source it does so as root (the daemon runs as root), and the deploy's own cp would then fail with Permission denied.

Create the deployment directories before first start:

sudo mkdir -p /opt/app-rewrite/{data,workspaces}
sudo chown -R 10001:10001 /opt/app-rewrite/{data,workspaces}

data/ and workspaces/ must be owned by the uid the app and agent-runner images bake for their repave user — 10001. No host account with that uid is needed; the containers only care about the number, so ls -l showing a bare 10001 is expected. The installer and the upgrade script both set this automatically, reading the uid from the app image rather than assuming it.

There is no claude/ directory: agent .claude config lives in a Docker named volume (see Runtime Paths below).

Runtime Paths​

Inside the app container:

/app/data
/app/workspaces/app-rewrite-work-dir
/app/.claude

Inside a web IDE session container the workspace is mounted at the project's own folder path; sessions are per-(project, feature) and do not share the app's mount layout.

On the host, choose deployment-owned persistent paths:

APP_DATA_HOST_DIR=/opt/app-rewrite/data
APP_WORKSPACE_HOST_DIR=/opt/app-rewrite/workspaces

Do not use developer-machine paths such as /Users/<name>/workspaces in client production.

Claude agent .claude config is not a host path. It lives in a shared Docker named volume (AGENT_CLAUDE_CONFIG_VOLUME, default app-rewrite-agent-claude), mounted into every agent-runner container at /home/repave/.claude. It holds the claude-mem plugin runtime and session transcripts (SDK resume), shared across agent containers and isolated from the host. There is no CLAUDE_CONFIG_HOST_DIR bind mount. Back it up with docker run --rm -v app-rewrite-agent-claude:/data -v "$PWD":/backup alpine tar czf /backup/agent-claude.tgz -C /data .

claude-mem runs in server mode: a shared claude-mem-server service (see docker-compose.yml) backed by the claude_mem database in the same Postgres container as the app, plus a valkey queue for generation jobs. Each project's memory store is a row scope in that database, keyed by a per-project API key (Project.claudeMemApiKey), not a per-project folder — there is no .claude-mem folder in the workspace anymore. Back up the claude_mem database the same way as the app database (see the Postgres backup section below); the agent-claude volume backup above no longer contains any project memory.

/app/data stores runtime artifacts such as traces, screenshots, project archive imports/exports, temporary uploads and ZIP extraction under /app/data/uploads, and bug report attachments.

/app/workspaces/app-rewrite-work-dir stores project workspaces, including legacy code, modernized code, generated UAT files, feature worktrees, and agent edits.

The web IDE is always available and always per-(project, feature). Opening a workspace — from the project sidebar under Develop -> View Source Code, or the "Open in VS Code" button in a feature's detail page header — starts a session container built from that project's devcontainer config. There is no enabling flag and no shared editor service: ENABLE_CODE_SERVER and CODE_SERVER_SESSIONS_ENABLED were removed along with the single shared code-server container once sessions became mandatory.

The browser URL stays on the Repave origin, for example http://localhost:3000/code-server/s/<session-id>/?folder=.... Only that session path is served; any other /code-server path returns 404, and no code-server service publishes a host port.

A session reaches Docker — Testcontainers-backed BDD runs, UAT stacks — through a private Docker-in-Docker sidecar with no route to the platform daemon. That isolation is the reason the shared instance is gone: a code-server terminal is an interactive human shell, and with the platform daemon reachable it could read the app container's secrets and escalate to host root. See design/devcontainer-web-ide.md and design/code-server-session-dind.md.

Opening a workspace lands on a progress page (…/code-server/starting) rather than a blank tab. It reports which phase the start is in and, on a first open, that building the environment image takes roughly 10–15 minutes. The build is not tied to that tab — closing it does not cancel the build, and reopening once it finishes is quick.

Session base images​

Every session is built FROM a first-party base published with the release and pinned in .env:

SESSION_BASE_NODE_IMAGE=<registry>/session-base-node:<version>
SESSION_BASE_JAVA_IMAGE=<registry>/session-base-java:<version>
SESSION_BASE_DOTNET_IMAGE=<registry>/session-base-dotnet:<version>

These carry the toolchain that devcontainer features used to install during the session build — Node on the Java and .NET bases, plus Maven and Gradle on Java. Baking them in is what lets a session build resolve nothing from ghcr.io and pull nothing from mcr.microsoft.com, which an airgapped client cannot reach at all and which cost every other client minutes on each cold open. The generated devcontainer.json therefore carries no features block beyond the locally staged repave-ide.

A project's own checked-in .devcontainer/devcontainer.json is left alone. The platform never reads it — the session image is built from a config regenerated from the project record, so generator changes reach existing projects with nothing to migrate — and it belongs to the client's repository, where it serves their developers' own "Reopen in Container" outside Repave.

Claude Code in a session​

The Claude Code CLI and VS Code extension are installed by the repave-ide devcontainer feature, which ships inside the release — no network needed. Each session gets its own container and a HOME that does not outlive it, so a developer's Claude sign-in is scoped to their session rather than shared across everyone who opens the editor.

Configure .env​

Create .env from .env.example:

cp .env.example .env

Set the image:

APP_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/server:1.0.0
DOCKER_HOST_IMAGE=alpine/socat:1.8.1.3
APP_PULL_POLICY=missing
DOCKER_HOST_PULL_POLICY=missing

For deployments that should always pick up the latest tag from a registry, use:

APP_PULL_POLICY=always

Publish Client Images​

Client-facing release images are published to the legacy-modernization Artifact Registry repository with neutral image names:

asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/server:<version>

Build and push the client images from the app repository root:

scripts/publish-client-images.sh 1.0.0

The script builds server from Dockerfile.app, plus the Claude agent runner image and the claude-mem server image (Dockerfile.claude-mem-server). It does not build or push Valkey or the CLIProxyAPI gateway sidecar — those are pulled-as-is third-party images, configured via VALKEY_IMAGE and CLI_PROXY_IMAGE.

Set the public URL:

REPAVE_PUBLIC_URL=https://repave.example.com

For a local VM or single-host deployment, this can be:

REPAVE_PUBLIC_URL=http://localhost:3000

NEXTAUTH_URL, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_BASE_URL and REPAVE_API_BASE_URL are deprecated aliases, still honoured so existing deployments upgrade without an .env change. Each logs a startup warning and will be removed in a future release.

docker-compose.yml exposes the app on host port 3000 on every interface, and Postgres on host port 5432 and claude-mem-server on 37877 on loopback only (127.0.0.1). Only the app, and nginx when its profile is on, are meant to be reachable from other machines. Postgres's credentials are fixed in docker-compose.yml, and Docker's published ports bypass host firewalls such as ufw . To reach the database from another machine, use an SSH tunnel (ssh -L 5432:127.0.0.1:5432 <host>) rather than widening the bind. If those ports are already in use, remap them in a Compose override file for the deployment.

A docker-compose.override.yml next to docker-compose.yml is the supported place for deployment-local customizations that must survive upgrades — port remaps, extra bind mounts (such as a private CA bundle), resource limits. The released docker-compose.yml is overwritten by every upgrade; the override file never is, and the installer and scripts/upgrade-client-compose.sh include it in every compose invocation exactly as a plain docker compose run from the deployment directory would (earlier releases did not — on those, re-run docker compose up -d from the deployment directory after an upgrade to reapply it). All four override spellings Compose accepts are honored with Compose's own precedence (compose.override.yml, compose.override.yaml, docker-compose.override.yml, docker-compose.override.yaml), but keep a single file named docker-compose.override.yml — multiple spellings draw a warning and only the winner applies. Note the flip side of full parity: a syntactically broken override fails every compose command — including the installer's doctor, verify, and uninstall — exactly as it fails plain docker compose; fix or temporarily rename the override file to get those tools back.

When running behind a reverse proxy or load balancer, keep REPAVE_PUBLIC_URL set to the browser-facing URL, not the container-internal URL.

Set required secrets:

NEXTAUTH_SECRET=<generate-a-long-random-secret>
INTERNAL_API_KEY=<generate-a-long-random-secret>

Generate secrets with:

openssl rand -base64 32

Set persistent host paths:

APP_DATA_HOST_DIR=/opt/app-rewrite/data
APP_WORKSPACE_HOST_DIR=/opt/app-rewrite/workspaces

Usually leave WORKSPACE_BASE_DIR unset. Docker Compose defaults it to:

/app/workspaces/app-rewrite-work-dir

If setting it explicitly, keep it as a container path:

WORKSPACE_BASE_DIR=/app/workspaces/app-rewrite-work-dir

Do not set it to a host path in Docker deployments.

AI Credentials​

Anthropic credentials can be configured in two ways:

  • Project settings in the app UI. This is preferred when keys differ by project.
  • Server-wide fallback in .env.

Server-wide fallback:

ANTHROPIC_API_KEY=<server-wide-key>
ANTHROPIC_BASE_URL=https://api.anthropic.com
CLAUDE_MODEL=sonnet

Every project must have its own anthropicApiKey/openaiApiKey configured in Settings — there is no deployment-wide fallback used for job dispatch (see the agent-runner design notes §7). For a project in a gateway-routed mode (API_KEY/OAUTH), saving its key automatically registers it with the CLIProxyAPI gateway; a project in ANTHROPIC_DIRECT or VERTEX mode registers nothing, because its credential is carried to the agent directly.

Do not assume a developer's interactive host login will work inside the container. OAuth/keychain-backed host login state is often not portable.

The Compose app service runs as the image's repave user (uid/gid 10001). Agent CLIs run as the repave user inside their runner images. The app prepares and mounts workspaces into those runner containers; if a runner image changes its agent uid/gid, set AGENT_WORKTREE_UID and AGENT_WORKTREE_GID accordingly.

No host account with that uid is created — the containers only care about the number, so ls -l showing a bare 10001 is expected. Upgrades re-align ownership automatically by reading the uid from the app image, so a change of uid in a future release needs no manual chown.

GitHub Integration​

No deployment configuration is needed for GitHub integration. Each project configures its own GitHub personal access token in the app under Settings → Integrations → GitHub (stored per project, like the project's AI keys). The settings section walks the user through creating a fine-grained PAT (Contents + Administration read/write), then supports creating/linking a repository. The link self-maintains from there: repository creation pushes the modernized branches immediately, a successful feature merge pushes automatically, and the Sync with GitHub button additionally pulls remote-only updates (fast-forward only — branches whose history diverged are reported for manual resolution, never merged server-side).

GitHub and Azure DevOps connections (personal access token)​

No deployment configuration is required. A user connects GitHub or Azure DevOps Services themselves, from the "Clone from a git repository" step of the new project form, with a personal access token — the same pattern Team Foundation Server already used (below). There is no OAuth app to register and no client id/secret to set: removed the OAuth connect flow, which no deployment ever configured in practice.

  • GitHub: a personal access token (repo read/write, read:user scopes).
  • Azure DevOps Services: an organization name (dev.azure.com/{organization}) plus a personal access token (Code: Read & Write).

The only relevant .env setting is the shared encryption key described below — GIT_OAUTH_ENCRYPTION_KEY covers GitHub, Azure DevOps, and TFS connections alike.

Notes:

  • GIT_OAUTH_ENCRYPTION_KEY encrypts every stored git connection's token, regardless of provider. Rotating or losing it invalidates saved git connections; users must reconnect.
  • It is optional. A deployment that does not set it gets a key generated on first use and recorded in the database, so nothing has to be configured before connecting a git server. Set it when you want the key kept out of the database — a stored key does not protect against a database dump, a backup or a read replica, which is the threat encryption at rest exists for.
  • A key already recorded in the database wins over this variable, deliberately: it is what existing tokens were encrypted with, so setting the variable later is ignored (with a warning in the app log) rather than silently breaking every stored connection. To adopt an env-provided key on a deployment that has generated one, users must reconnect.
# Encrypts stored git connection tokens at rest (AES-256-GCM). Generate once:
# openssl rand -base64 32
GIT_OAUTH_ENCRYPTION_KEY=<base64-32-byte-key>

TFS_ALLOWED_HOSTS — optional bound for Team Foundation Server (Issues #783, #871)​

Self-hosted Team Foundation Server / Azure DevOps Server has no OAuth, so a connection is a server URL plus a personal access token. That URL is supplied by an authenticated user and the app then issues a server-side request to it, from inside the client's network — so unlike every other provider, which has a hardcoded host, it needs an explicit boundary.

TFS_ALLOWED_HOSTS: "tfs.client.internal,tfs2.client.internal:8443"
  • Comma-separated hosts, or host:port. An entry without a port permits any port on that host; an entry with a port permits only that port. Matching is case-insensitive and never a suffix match.
  • Unset means no deployment-level restriction : Team Foundation Server is offered, and the server URL a user enters at connect time becomes what that connection may reach. Every later call — REST, project creation, clone — must still match the host that connection was created with, so a connection can never be used against a different server. What is given up is the deployment's say in which servers: any user of this deployment can point it at any host it can reach, and a mistyped host receives the PAT.
  • Set it if you want that say. With a list present nothing has changed: a host outside it is refused at connect time and on every later call, so tightening the list still stops projects created under the old one. This is the recommended setting for a deployment whose users are not all trusted to name internal hosts.
  • Blocking private address ranges is not a substitute — the client's server is on a private range. The allowlist, when set, is the control.

A server behind an internal CA​

If the TFS server presents a certificate issued by the client's own internal CA, paste that CA's PEM chain — or upload its .pem/.crt file — into Private CA certificate on the Connect Team Foundation Server form, and press Test connection before saving. The certificate is stored on the connection and used by every path that talks to that server: the REST API, and git clone, fetch and push alike. No mount, no image rebuild, and no restart are required, and nothing needs to be added to this file.

The test reports each cause separately — the certificate not being trusted, a certificate issued for a different host name, a rejected token, an unreachable host — because they have different fixes.

Two things this deliberately is not:

  • It does not skip certificate verification. Trust is added in the one CA supplied; a certificate that CA did not issue is still refused.
  • If you cannot supply the issuing CA at all — it is unobtainable, the server sends its leaf without the intermediates, or the certificate names a different host than the URL you must use — the connect form has a Skip certificate verification option . It applies to that one connection, not to the deployment, and it accepts any certificate that server presents, so prefer the CA field whenever you can get the certificate. It can also be turned on or off later from the project's Git connection settings card. Note that the connection is per user and shared by every project pointed at that server.
  • It does not replace the container's trust store. A deployment that has already installed its CA into the image keeps working unchanged, and needs nothing here.

Upgrading from a CA installed in the container​

Nothing to do, and nothing is migrated. If you already trust your CA by baking it into a derived image (update-ca-certificates) or by bind-mounting it and setting GIT_SSL_CAINFO / NODE_EXTRA_CA_CERTS on the app service, that keeps working exactly as before: with no certificate stored on the connection, Repave adds no CA of its own and leaves those settings untouched.

The certificate is deliberately not copied into the connection for you. Neither the released image nor docker-compose.yml ships a place to put one, so every such setup is local to your deployment and there is no field to read; and the container store adds your CA to the public roots, whereas a certificate on a connection replaces the trust store for requests to that server. Guessing which certificate to copy could therefore narrow trust and break the next clone — the failure this field exists to prevent.

Moving to the field is optional and can wait for a convenient release. If you do move:

  1. Paste the CA that issued the TFS server's certificate, with any intermediates.
  2. Press Test connection before saving. This is the check that matters — the connection's certificate replaces the container's for that server, so a chain that is incomplete, or a mounted bundle that happened to cover several hosts, shows up here rather than in a clone job.
  3. Only once it passes, remove the mount or the derived image layer if you no longer want it.

Keeping both is fine and is the safe default: the connection's certificate simply wins for that server.

UAT And Docker Host Access​

The Compose stack includes a docker-host service that mounts /var/run/docker.sock and exposes it as tcp://docker-host:2375 only on the private Compose network. The app service does not mount the Docker socket directly; it runs as repave and sets DOCKER_HOST=tcp://docker-host:2375.

The trusted app process uses this daemon for platform-side container orchestration such as UAT and preview lifecycle management. Untrusted job-scoped workloads do not inherit it by default: agent containers, standalone BDD test containers, and Docker-mode unit-test containers acquire a private per-job network plus a private dind daemon through agent-sandbox-service.ts. DOCKER_AGENT_NETWORK and the platform daemon remain only as the explicit AGENT_SANDBOX_ENABLED=false rollback wiring.

This preserves Docker Compose and Testcontainers capability for generated BDD and unit-test suites without granting their code access to the platform daemon.

The private daemon keeps a per-project image cache between jobs (, ), so a BDD run does not pull and extract its database image — several GB for SQL Server — on every job. Each project has up to maxParallelAgents cache volumes, repave-agent-dind-cache-<projectId>-<n>, leased to one job at a time. Before a job sees the daemon, everything an earlier job left is removed except images a registry served under the same name, and those are pulled again after 7 days. Changing the registry mirror, CA, image-name prefixes, registry credential or the dind image empties the cache. A job with no project, or one beyond the project's limit, starts on an empty throwaway volume as before. There is no setting for any of this.

Web IDE sessions follow the same model with a private dind sidecar per session (design/code-server-session-dind.md). The session images ship a docker client with the Compose and buildx plugins, baked in at release-build time, so a BDD suite that starts its database with docker compose runs from the IDE terminal on an airgapped host too (design/session-docker-cli.md). That client can reach only the session's own daemon.

Security note: mounting the Docker socket gives the docker-host service control over the host Docker daemon. Only enable this deployment mode on hosts dedicated to Repave or hosts where that trust boundary is acceptable. The TCP proxy is intentionally not published to the host.

The Compose app keeps HOME=/app for app and agent compatibility, but moves common tool caches and state to writable /tmp/repave-* paths for non-root execution. This includes XDG config/cache/state, Git global config, npm cache/user config, .NET/NuGet, Gradle, Maven, and pip cache/config variables.

Relevant defaults:

UAT_HOST=0.0.0.0
UAT_PUBLIC_HOST=localhost
TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal
DOCKER_HOST=tcp://docker-host:2375
# DOCKER_AGENT_NETWORK=

With AGENT_EXECUTION_MODE=docker, each job workload starts as a sibling Docker container. The platform Docker client still resolves that workload's bind-mount source paths on the host, so the app must know the host-side workspace mount:

APP_WORKSPACE_HOST_DIR=/opt/app-rewrite/workspaces
APP_WORKSPACE_CONTAINER_DIR=/app/workspaces

The Compose defaults set APP_WORKSPACE_HOST_DIR from the same value used by the /app/workspaces bind mount. For non-standard mounts, set DOCKER_PATH_MAPPINGS as semicolon-separated container=host mappings, with the most specific mappings taking precedence:

DOCKER_PATH_MAPPINGS=/app/workspaces/app-rewrite-work-dir=/mnt/fast/app-rewrite-work-dir

Sandboxed agents, standalone BDD tests, and unit-test runners receive their private dind DOCKER_HOST, not the mounted socket or docker-host proxy. Their outer workload containers mount the containing project workspace while retaining their exact workdir, so suites can load workspace-adjacent image archives into the private daemon. A loaded image is not kept by the image cache — only registry-served images are — so a suite that loads one does so on every job. TESTCONTAINERS_HOST_OVERRIDE is deliberately omitted in a sandbox; connection targets derive from the dind hostname in DOCKER_HOST.

In Docker agent mode, every job — Claude-tier and OpenAI-tier alike — runs in the same runner image, AGENT_RUNNER_IMAGE (default app-rewrite-agent-runner:latest) from Dockerfile.agent-runner; there is no separate Codex/OpenAI runner image. The project/provider selection only decides which model answers, via enabledAiProviders and the tier mappings — every dispatch routes through the CLIProxyAPI gateway (cli-proxy-api service, CLI_PROXY_IMAGE), authenticated with that project's own registered credential. Released Compose bundles pin AGENT_RUNNER_IMAGE/CLI_PROXY_IMAGE to the published registry tags.

By default, browser access to UAT sessions goes through the app's same-origin reverse proxy (/api/projects/<id>/uat/proxy/<target>/), so no UAT port needs to be reachable from reviewers' browsers. Deployments running the nginx profile can instead serve each session at the root of its own HTTPS port — see UAT on its own HTTPS port. UAT_PUBLIC_HOST only changes the host shown in the UAT page's direct URL text. Set it when that direct URL should name a browser-reachable host (and you have exposed the UAT ports yourself):

UAT_PUBLIC_HOST=repave.example.com

APP_CONTAINERIZED=1 is set by docker-compose.yml. VM-hosted Node deployments should leave APP_CONTAINERIZED unset, but Docker Compose deployments should not override it.

Pinning A Hostname For Containers​

An entry in the host's /etc/hosts never reaches a container. Docker builds a fresh /etc/hosts for every container it creates and copies only the host's /etc/resolv.conf nameservers, so a hostname you have pinned on the VM resolves from the VM shell and fails from every container the stack runs. Job containers are additionally on a private bridge and resolve through Docker's embedded DNS at 127.0.0.11, which forwards to those same nameservers — still no path to the host's hosts file.

Where you can run one, the right fix is a local resolver serving those names plus "dns" in /etc/docker/daemon.json. That covers every layer, including containers nested inside a job's own Docker daemon, and needs no configuration here. Use the two settings below only when you cannot.

# Applied by the app to every container it spawns: agent runners, unit-test,
# BDD, coverage, preview and playground containers, and web IDE sessions.
# Docker's own --add-host format, comma-separated, any number of entries.
DOCKER_EXTRA_HOSTS=oauth2.googleapis.com:10.20.30.40,sts.googleapis.com:10.20.30.42

# The same entries again, one per variable, for the long-running Compose
# services that make such calls themselves (app, cli-proxy-api,
# claude-mem-worker). Three slots; leave unused ones unset.
EXTRA_HOST_1=oauth2.googleapis.com:10.20.30.40
EXTRA_HOST_2=sts.googleapis.com:10.20.30.42

Both are needed because Compose cannot expand one variable into a YAML list. Two rules avoid the sharp edges:

  • Do not set two EXTRA_HOST_* slots to the same value, and do not set one to host.docker.internal:host-gateway. Compose folds extra_hosts into a mapping and rejects the whole file on a duplicate hostname, with services.app.extra_hosts must be a mapping.
  • A malformed DOCKER_EXTRA_HOSTS entry fails the job that reads it, by design. The address must be an IPv4/IPv6 literal (or host-gateway), not another hostname. Silently dropping a typo would leave the original problem in place and surface it hours later as an unrelated failure. Pinning host.docker.internal is rejected for the same reason: every spawned container already maps it to the host gateway, and /etc/hosts is first-match, so the pin would be accepted and then have no effect.

Neither setting reaches containers started inside a job's or session's nested Docker daemon. Nothing there needs it today; if that changes, it needs --dns on the inner daemon rather than another entry here.

Start​

Standard start​

Enable the cli-proxy-api gateway profile first, unless every project on this deployment will use Claude-on-Vertex mode. It's disabled by default — any project using an API key or OAuth for Claude/OpenAI needs it, which is most deployments today:

COMPOSE_PROFILES=cli-proxy-api

From the deployment directory:

docker compose pull
docker compose up -d postgres docker-host claude-mem-server claude-mem-worker cli-proxy-api
docker compose run --rm migrate
docker compose up -d --no-deps app

Leave COMPOSE_PROFILES unset and omit cli-proxy-api from the up -d line above only for an all-Vertex deployment.

Or with the packaged release deploy script:

scripts/deploy-client-release.sh <version>

There is no Code Server profile to start or skip: the web IDE is per-session and starts its containers on demand. A deployment upgraded from a release that had the shared code-server service enabled has its old container retired automatically by upgrade-client-compose.sh; to remove one by hand:

docker rm -f "$(docker ps -aq --filter label=com.docker.compose.service=code-server)"

With Nginx (TLS Termination)​

For deployments exposed directly on a public host with no existing load balancer or TLS termination in front of them. Certificates are supplied externally (e.g. host-level certbot renewing into NGINX_TLS_CERT_HOST_DIR, default /etc/letsencrypt) — this service does not issue or renew them.

Create the real vhost config from the tracked example and edit the domain:

cp nginx/conf.d/repave.conf.example nginx/conf.d/repave.conf
# edit nginx/conf.d/repave.conf: replace CHANGE_ME.example.com with the
# deployment's real domain, and confirm the ssl_certificate paths match a
# domain actually issued under NGINX_TLS_CERT_HOST_DIR.

In .env, activate the nginx profile and bind app to loopback so nginx is the sole public entry point. There is no separate ENABLE_NGINX flag — COMPOSE_PROFILES (or --profile nginx on each command) is the only thing that starts the service. Combine it with any other active profile as a comma-separated list, not by repeating the variable:

COMPOSE_PROFILES=nginx
# or, combined with the cli-proxy-api gateway profile (see "Standard start"
# above), as a comma-separated list rather than by repeating the variable:
# COMPOSE_PROFILES=cli-proxy-api,nginx
APP_HOST_BIND=127.0.0.1
# NGINX_TLS_CERT_HOST_DIR=/etc/letsencrypt
# APP_HOST_PORT=3000

COMPOSE_PROFILES=nginx and APP_HOST_BIND=127.0.0.1 are two separate switches — nothing in Compose enforces that they're set together. Enabling the nginx profile without also loopback-binding app leaves app reachable directly on its published host port, unencrypted, completely bypassing TLS termination. Always set both when enabling this profile.

APP_HOST_PORT only matters when APP_HOST_BIND is left at its 0.0.0.0 default (no reverse proxy in front); it has no effect once app is loopback-bound, since nginx reaches it over the internal Compose network, not the published host port.

The database password​

Postgres and claude-mem-server are published on 127.0.0.1 by the Compose file itself , so there is nothing to configure for either. app is the one that still takes a bind address, above.

POSTGRES_PASSWORD sets the database password, which every connection URL in docker-compose.yml is built from. It defaults to the historical apppassword; a deployment on a host you do not fully control should set its own:

POSTGRES_PASSWORD=a-long-random-value

POSTGRES_PASSWORD can only be set on a fresh volume. Postgres reads it when it initialises an empty data directory and never again, so setting it on a stack that already has a postgres_data volume locks the app out of its own database with an authentication error. Set it before the deployment's first up, or leave it alone. Rotating it on a live deployment is a manual ALTER ROLE plus an .env edit, not a restart.

Who may create an account​

REPAVE_SIGNUP_MODE is open by default: anyone who can reach the sign-up page gets an account. On a host reachable from the internet, set it to invite-only, which allows an account only for an email address with a pending, unexpired project invitation:

REPAVE_SIGNUP_MODE=invite-only

This closes both doors — POST /api/auth/signup and the Firebase sign-in upsert — and the sign-in page replaces its Sign Up affordance with "I have an invitation". An unrecognised value stops the app at startup rather than defaulting back to open.

The first account on a deployment with no users is created out of band, without signing up:

printf '%s' "$password" | docker compose run --rm -T app npm run bootstrap-admin

It creates admin@repave.local when the user table is empty and is a no-op afterwards, so it is safe to re-run. -- --reset replaces that account's password instead, which is the recovery path for a locked-out administrator (there is no forgot-password-by-email flow). The password is read from stdin and never accepted as an argument, since arguments are visible in ps output and in docker inspect.

Start nginx last, after app is already up and migrated — it depends on app being started, so bringing it up earlier would start app implicitly, ahead of migrations:

docker compose --profile nginx pull
docker compose up -d postgres docker-host claude-mem-server claude-mem-worker cli-proxy-api
docker compose run --rm migrate
docker compose up -d --force-recreate --no-deps app
docker compose --profile nginx up -d --no-deps nginx

After the host's certbot renews a certificate, reload nginx so it picks up the new files (nginx does not watch the mounted cert directory for changes):

docker compose exec nginx nginx -s reload

Wire that command into certbot's renewal as a deploy-hook so it happens automatically, rather than relying on a manual reload after every renewal.

UAT on its own HTTPS port​

Optional, and only with the nginx profile. UAT Tools normally shows the app under test through a path on the platform (/api/projects/<id>/uat/proxy/<target>/), which the app does not know about: its own absolute links, requests and WebSockets can point at the platform instead of the app. With a gateway, each running UAT session's app is served at the root of its own port on the platform's host, with the platform's certificate — the certificate names the host, not the port, so nothing about it changes. See .

  1. Pick a range with one port per UAT session you expect to run at once, across all projects (each project runs up to its Maximum running environments, default 5), and set it in .env:

    UAT_GATEWAY_PORTS=8443-8452

    INTERNAL_API_KEY must also be set: nginx uses it to ask the platform which app runs on a port.

  2. Create the gateway config from the tracked example. Replace every CHANGE_ME: the listen range (twice) must equal UAT_GATEWAY_PORTS, server_name and the certificate paths must match repave.conf's :443 block, $repave_origin is the platform URL reviewers use, and the key is INTERNAL_API_KEY. The copy is gitignored, like repave.conf.

    cp nginx/conf.d/uat-gateway.conf.example nginx/conf.d/uat-gateway.conf
  3. Block the lookup on the public server. repave.conf.example now refuses /api/internal/uat-gateway/ on :443; copy that location block into an existing repave.conf.

  4. Publish the range on the nginx service in docker-compose.override.yml:

    services:
    nginx:
    ports:
    - "8443-8452:8443-8452"
  5. Open the range in the firewall (an Azure NSG rule, or the host's own rules). If the UAT view shows a blank frame or "can't connect", this is the usual cause; the view names the port.

  6. Restart the app (to read UAT_GATEWAY_PORTS) and nginx:

    docker compose up -d --force-recreate --no-deps app
    docker compose --profile nginx up -d --force-recreate --no-deps nginx

When every port is taken, a new UAT start fails with a message naming UAT_GATEWAY_PORTS. The app under test is reachable on its port without signing in; the component picker is served only to signed-in reviewers.

Upgrading to this release signs everyone out once. The platform's session cookies were renamed from authjs.* to repave.* so that an app under test using Auth.js's default names cannot overwrite them — they share a cookie jar once the app is on the same host.

Check status:

docker compose ps
curl -fsS http://localhost:3000/api/health

Expected health response:

{"status":"ok","version":"<release-version>"}

docker compose ps reports app as Up (health: starting) during cold start, then Up (healthy) once it serves that response — the container runs the same probe internally every 10s. Up (unhealthy) means the process is alive but not answering, which is the one failure mode the restart policy cannot see: Docker does not restart a container for being unhealthy, only for exiting. Restart it by hand with docker compose restart app, and check docker compose logs app first — an unhealthy app that has not exited usually has something worth reading in its log.

With the nginx profile, nginx waits for app to be healthy before starting, so a failed app surfaces as dependency failed to start on docker compose up rather than as a 502 from a proxy pointing at a dead upstream.

Open:

http://localhost:3000

or the configured external URL.

Logs​

Follow app logs:

docker compose logs -f app

Follow database logs:

docker compose logs -f postgres

Check paths inside the app container:

docker compose exec app sh -lc 'printf "HOME=%s\nWORKSPACE_BASE_DIR=%s\nDATA_DIR=%s\n" "$HOME" "$WORKSPACE_BASE_DIR" "$DATA_DIR"; ls -ld /app/data /app/workspaces /app/workspaces/app-rewrite-work-dir'

Check installed CLIs and runner images:

docker compose exec app sh -lc 'repave --help >/dev/null'
docker image inspect "$(awk -F= '$1 == "AGENT_RUNNER_IMAGE" { print $2 }' .env)" >/dev/null

Stop​

Stop containers without deleting data:

docker compose stop

Stop and remove containers while preserving named volumes and bind-mounted data:

docker compose down

Do not use docker compose down -v unless intentionally deleting the Postgres volume.

Update​

Both scripts/deploy-client-release.sh and scripts/upgrade-client-compose.sh check free space on Docker's data-root (docker info --format '{{.DockerRootDir}}') before touching anything, and exit with a clear error (not a mid-pull failure) if it's below MIN_FREE_DISK_MB (default 5120, i.e. 5GB — override if this is a false positive for a particular host). deploy-client-release.sh also fails fast with an actionable message if gcloud has no valid credentials, rather than surfacing the raw gcloud.storage.cp reauthentication error mid-download. Neither check covers a separate containerd data dir some hosts run outside Docker's own data-root (check containerd.service's config / /var/lib/containerd by hand with df -h if the host has one) — a host near 100% full even after these checks pass can still fail mid-pull with no space left on device, and the deploy script's own rollback recovers cleanly but leaves you no better off. If the host has a mostly-idle secondary disk, relocating Docker's (and, if present, containerd's) data-root there — stop docker/containerd, rsync /var/lib/docker and /var/lib/containerd to the new location, replace the originals with symlinks, confirm the new disk is in /etc/fstab with nofail so it remounts on reboot before Docker starts, then restart both services — is a durable fix.

Uploads add a second, spikier claim on the same disk. nginx buffers each request body before proxying it, so an upload near client_max_body_size (11g in the shipped example) is briefly that large on disk, and concurrent uploads multiply it. Those bytes go to the nginx_body_temp volume, which makes the space explicit — docker system df -v shows it — but a named volume still lives under Docker's data-root by default, so declaring it does not by itself move the exposure off the disk that wedges the daemon. Either size the data-root for client_max_body_size × the concurrent uploads you expect, or point the volume elsewhere with driver options (driver_opts with type: none, o: bind, device: /path/on/another/disk). Relocating the data-root, as above, covers this too.

One consequence of that volume worth knowing: nginx removes a body temp file when its request finishes, but not when it is killed mid-upload — SIGKILL, an OOM kill, a host reboot — and it does not sweep the directory at startup. Before the volume those orphans died with the container's writable layer on the next up, which every release upgrade performs; now they persist. An nginx killed during a 10 GB upload leaves 10 GB parked under the data-root indefinitely, visible only in docker system df -v. If free space drops unexpectedly, check it and clear old entries: docker compose exec nginx find /var/cache/nginx/client_temp -type f -mtime +1 -delete.

Never bring the stack up with docker compose up -d --no-recreate after changing APP_IMAGE. A one-shot service keeps whatever image it was created with, so --no-recreate reuses migrate, ownership-init and claude-mem-db-init containers still pinned to the previous release. Use docker compose rm -f migrate (and the other one-shots) first, or let the upgrade script recreate them. The migrate container refuses to run when its baked release does not match the APP_IMAGE currently in the deployment's .env — see the stale-migrate entry under Troubleshooting.

To update to a new app image, edit .env with the new APP_IMAGE tag, then run the packaged upgrade script from the deployment directory:

scripts/upgrade-client-compose.sh

The script creates a backup before changing running services. The default backup directory is:

backups/pre-upgrade/<timestamp>/

It includes:

  • appdb.dump: custom-format PostgreSQL dump created with pg_dump -Fc
  • claude_mem.dump: custom-format PostgreSQL dump of the claude_mem database (claude-mem server-mode memory store), skipped if that database does not exist yet
  • every compose file the upgrade ran with — docker-compose.yml plus the override file when one exists (or, with COMPOSE_FILE set, each listed file), copied under its basename and listed on the manifest's composeFiles= line — and .env
  • tarballs for APP_DATA_HOST_DIR and APP_WORKSPACE_HOST_DIR when those paths exist
  • the app-rewrite-agent-claude Docker named volume (Claude agent config and claude-mem plugin runtime, no project memory) — export via docker run --rm -v app-rewrite-agent-claude:/data -v "$PWD":/backup alpine tar czf /backup/agent-claude.tgz -C /data .

For non-default health URLs or backup locations:

HEALTH_URL=http://localhost:3000/api/health \
BACKUP_ROOT=/opt/legacy-modernization/backups \
scripts/upgrade-client-compose.sh

This is a different BACKUP_ROOT from scripts/upgrade-client-server.sh's. That script is the SSH-orchestrated path used to operate an existing client host from a Repave checkout, rather than the packaged self-service script above, and takes its backups under $DEPLOY_DIR/backups (its own independently-defaulted BACKUP_ROOT) via scripts/_client-upgrade-remote.sh. It also prunes automatically after each backup, keeping the last BACKUP_RETENTION_COUNT (default 5) files per kind — appdb dump, claude_mem dump, config snapshot — since nothing else ever cleans those up and each is a multi-GB dump. Point its BACKUP_ROOT at a larger disk when the host's root filesystem is small relative to its Docker data disk; a deployment whose root filesystem is small relative to its Docker data disk needs this set.

The app re-registers every project's credential by itself on startup , so the manual re-save below is a fallback, not the normal path. reconcileAllProjectGatewayCredentialsOnStartup (src/lib/services/cli-proxy-credential-provisioning.ts, called from src/instrumentation-node.ts) re-provisions every gateway-routed project holding a key each time the app container starts, and dispatch re-registers a project whose credential the gateway has forgotten before launching the job. A restart that takes app and cli-proxy-api down together — a host reboot, a release upgrade — therefore heals on its own, and a cli-proxy-api-only restart heals on the next job. Watch for [GatewayCredentials] startup reconcile: N/M gateway-routed projects registered in docker compose logs app. Everything below still applies when that reconcile itself fails (an unreachable gateway, a GATEWAY_MANAGEMENT_SECRET desync — the reconcile logs the failing project ids and moves on), and on any deployment still running a release from before this fix. See .

Any cli-proxy-api container recreation triggers a "credential pool is empty" problem, not just the first gateway crossing. The Compose service's entrypoint copies the host's cli-proxy-api/config.yaml into the container's own writable path on every startup (see the service definition in docker-compose.yml), but the management API that registers per-project credentials writes into that same container-local copy — never back to the host file. So every recreation (new CLI_PROXY_IMAGE tag, any change to the cli-proxy-api service block, or a plain docker compose up -d --force-recreate cli-proxy-api) silently discards every project's registered credential, even on a deployment that's been running the gateway for months. Both deploy-client-release.sh and upgrade-client-compose.sh detect this automatically now — they compare cli-proxy-api's container ID before/after starting support services and print an "ACTION REQUIRED" note at the end whenever it was created or recreated (not just on config.yaml not existing yet, the original narrower check). Treat every project with a configured anthropicApiKey/openaiApiKey as needing re-registration whenever that note appears — check what's actually loaded and re-save Settings (or call provisionProjectGatewayCredentials for that project directly) for each one that's missing:

docker compose logs cli-proxy-api --tail 20 | grep "clients and configuration updated"
# compare the "N Claude API keys" / "N Codex keys" count against how many
# projects actually have anthropicApiKey/openaiApiKey set:
docker compose exec -T postgres psql -U appuser -d appdb -t -c \
'SELECT id FROM projects WHERE "anthropicApiKey" IS NOT NULL OR "openaiApiKey" IS NOT NULL;'

Don't trust a clean Settings-save or a successful-looking management-API call as proof the gateway is actually serving that project's traffic — verify with a real request:

docker compose exec -T app sh -c '
curl -s -X POST http://cli-proxy-api:8317/v1/messages \
-H "x-api-key: $GATEWAY_AUTH_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "{\"model\":\"proj-<projectId>/<model-id>\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"say hi\"}]}"
'

A real Claude response confirms it; a 502/{"type":"error",...} means something is still misconfigured.

A freshly-generated GATEWAY_MANAGEMENT_SECRET is re-synced into an already-existing config.yaml automatically. Both deploy-client-release.sh and upgrade-client-compose.sh call ensure_secret/ensure_env_secret GATEWAY_MANAGEMENT_SECRET, which writes a brand-new random value to .env whenever that var is missing — but provision_cli_proxy_config in both scripts only renders cli-proxy-api/config.yaml when the file doesn't already exist, and skips entirely otherwise. A deployment that already had a config.yaml but was missing GATEWAY_MANAGEMENT_SECRET from .env (e.g. from a partial or older gateway setup) used to end up with a .env secret that didn't match config.yaml's remote-management.secret-key — every management-API call then 403s, including credential registration itself, which a Settings re-save cannot repair because the save's own provisioning call is what's being rejected. sync_cli_proxy_management_secret (scripts/lib/deploy-helpers.sh, called right after provision_cli_proxy_config in both scripts) now compares the two and rewrites config.yaml's secret-key line — and only that line — to .env's value whenever they differ, then restarts cli-proxy-api so the entrypoint copies the corrected file in. .env is the source of truth: the app authenticates with process.env.GATEWAY_MANAGEMENT_SECRET, so the file syncs to it and never the other way round. The restart empties the credential pool, which the app's own startup reconcile repopulates on the app recreate that immediately follows (see the reconcile note above). Write the secret as PLAINTEXT, identical to .env — do not bcrypt-hash it. CLIProxyAPI self-hashes secret-key on load and rewrites its own container-local copy, so a $2a$ value in the live container is normal and is not evidence of drift; a $2a$ value in the host file (what a docker compose exec cli-proxy-api cat /CLIProxyAPI/config.yaml > cli-proxy-api/config.yaml dump leaves behind) still authenticates the matching plaintext — measured 2026-08-09 on v7.2.104 — so it is not broken, but the sync normalizes it back to plaintext anyway, because a hash of a stale secret looks identical to a hash of the current one and would let the next rotation desync silently. Diagnose a suspected desync with node -e "require('bcryptjs').compareSync(plaintext, liveHash)", not by eyeballing the hash. remote-management.allow-remote: false is still only warned about (warn_cli_proxy_config_drift), not auto-fixed: unlike the secret there is no value derivable from .env to sync it to, and it can be a deliberate operator choice — with it false, every management-API call from the app container fails regardless of the secret, since it isn't calling from cli-proxy-api's own localhost. Flip it to true and restart cli-proxy-api.

These three gotchas can compound, and have: on one upgrade the disk was full (blocking the first attempt), the gateway sidecar's service definition changed (recreating the container and wiping its registered credential), and GATEWAY_MANAGEMENT_SECRET was freshly generated against a stale config.yaml hash with allow-remote: false — all three had to be fixed before the affected project's agent jobs worked again.

The agent sandbox's per-project image caches are Docker volumes too, labelled app-rewrite.kind=agent-dind-cache — list them with docker volume ls --filter label=app-rewrite.kind=agent-dind-cache. Each holds its project's test database images (roughly 1.5–3 GB for SQL Server), and a project has at most maxParallelAgents of them. They are removed with their project, and at app startup if their project is gone. Removing one by hand is safe whenever no repave-agent-dind-* container mounts it: the next job starts cold and pulls again. docker image prune does not reach them.

After a successful upgrade, old release images can be removed to reclaim disk:

docker image prune -a

When scripts/deploy-client-release.sh is used directly, it prunes old unused Repave server image tags by default before and after deployment. Set PRUNE_OLD_RELEASE_IMAGES=0 to keep old release images on that deploy path.

Manual update flow, if the script cannot be used:

  1. Edit .env and set the new APP_IMAGE tag.
  2. If this deployment needs the CLIProxyAPI gateway and hasn't provisioned it yet (no GATEWAY_BASE_URL in .env yet — the gateway is disabled by default, ), provision it — the script's provision_cli_proxy_config does this automatically when cli-proxy-api is in COMPOSE_PROFILES, but by hand:
    # Activate the profile and generate the two gateway secrets, pointing the app at the sidecar.
    grep -q '^COMPOSE_PROFILES=' .env || echo "COMPOSE_PROFILES=cli-proxy-api" >> .env
    grep -q '^GATEWAY_AUTH_TOKEN=' .env || echo "GATEWAY_AUTH_TOKEN=$(openssl rand -base64 32)" >> .env
    grep -q '^GATEWAY_MANAGEMENT_SECRET=' .env || echo "GATEWAY_MANAGEMENT_SECRET=$(openssl rand -base64 32)" >> .env
    grep -q '^GATEWAY_BASE_URL=' .env || echo "GATEWAY_BASE_URL=http://cli-proxy-api:8317" >> .env

    # Render cli-proxy-api/config.yaml from those same secrets (skip if it
    # already exists — never overwrite a config already holding real values).
    test -f cli-proxy-api/config.yaml || cat > cli-proxy-api/config.yaml <<EOF
    host: ""
    port: 8317
    remote-management:
    allow-remote: true
    secret-key: "$(grep '^GATEWAY_MANAGEMENT_SECRET=' .env | cut -d= -f2-)"
    auth-dir: "/root/.cli-proxy-api"
    api-keys:
    - "$(grep '^GATEWAY_AUTH_TOKEN=' .env | cut -d= -f2-)"
    debug: false
    EOF
    The credential pool starts empty — see the "Upgrading a pre-gateway installation" note above for the required one-time Settings re-save per project. Skip this step entirely on a deployment that already has GATEWAY_BASE_URL set, or that doesn't need the gateway at all — every project on a gateway-bypassing mode (ANTHROPIC_DIRECT, the default for new projects, or VERTEX).
  3. Pull the image.
  4. Stop the app so old code is not serving while migrations run.
  5. Run the one-shot migration service.
  6. Recreate the app container.
docker compose pull app migrate docker-host claude-mem-server valkey
docker compose stop app
docker compose up -d postgres docker-host cli-proxy-api claude-mem-server claude-mem-worker
docker compose run --rm migrate
docker compose up -d --force-recreate --no-deps app
docker compose up -d --force-recreate claude-mem-server claude-mem-worker valkey

If the Compose file changed:

docker compose up -d

Check health after update:

curl -fsS http://localhost:3000/api/health

Schema changes are applied only by the migrate service, which runs the packaged scripts/prisma-migrate-deploy.sh.

On an empty database, the script bootstraps the initial schema from prisma/schema.prisma, records the packaged migrations as applied, and then verifies there are no pending migrations. This is only for first install, where there is no data to backfill. On databases that already have Prisma migration history, the script runs npx prisma migrate deploy.

After migrations run, the script validates the live database schema against the packaged Prisma schema. If drift remains, the script exits before the app starts serving traffic. Treat that as a release migration gap or an unbaselined pilot database, not as an app runtime problem to ignore.

It then applies prisma/migration-only-objects.sql — the partial indexes and CHECK constraints that prisma/schema.prisma cannot express, and that the schema validation above therefore cannot see — and exits if any of them is still missing afterwards. This runs on every database state, so an install bootstrapped before this existed gains them on its next upgrade. If existing rows violate one, the script names those rows and stops; resolve them and re-run.

Do not use prisma db push for client or production upgrades; it bypasses migration history and skips custom SQL backfills in prisma/migrations.

Existing Db-Push Installations​

Older pilot deployments used Prisma schema push during app startup. Those databases may already have the current table shape but no _prisma_migrations history. Before moving one of those installations onto migration-based releases, baseline the currently deployed image first:

docker compose stop app
PRISMA_BASELINE_EXISTING_SCHEMA=1 docker compose run --rm migrate

The baseline path first checks that the live schema matches the packaged Prisma schema. It refuses to baseline if Prisma reports a schema diff. It does not run db push against an existing database.

If a pilot database was already started with a newer image before this baseline was recorded, rerun the baseline against that newer image and name the first migration that still needs to execute:

PRISMA_BASELINE_EXISTING_SCHEMA=1 \
PRISMA_BASELINE_BEFORE_MIGRATION=<first-new-migration-directory> \
docker compose run --rm migrate

Use this only when you know the database already matches the schema immediately before that migration.

Some pilot databases may have partial migration history plus schema objects created by older schema-push startup code. Those databases can fail on old migrations that try to create already-existing objects, or they can appear migrated while still missing columns used by the app. Current releases include a reconciliation migration for known pilot drift and the migration script verifies the final schema. If migration validation fails, inspect the Prisma diff, add a forward-only migration for the missing schema change, and rerun the migrate service before starting app.

Run that baseline only against the image version that is already deployed and reflected in the live database. After pulling a newer image, use the normal update flow above so only new migrations are executed. For scripted deployments, setting AUTO_BASELINE_PRISMA_MIGRATIONS=1 makes scripts/deploy-client-release.sh perform the same baseline check before swapping to the new image. If a previous schema-push release contained manual data backfills, review those migrations and apply any missed idempotent backfill SQL before marking the installation upgraded.

Backup​

Back up these items together:

  • Postgres databases: appdb and claude_mem (claude-mem server-mode memory store)
  • APP_DATA_HOST_DIR
  • APP_WORKSPACE_HOST_DIR

(Claude agent config lives in the app-rewrite-agent-claude Docker volume, not a host dir — see the backup command above.)

Postgres logical backup:

docker compose exec -T postgres pg_dump -U appuser -d appdb > appdb.sql
docker compose exec -T postgres pg_dump -U appuser -d claude_mem > claude_mem.sql

Runtime artifact backup:

tar -czf app-rewrite-data.tgz -C /opt/app-rewrite data
tar -czf app-rewrite-workspaces.tgz -C /opt/app-rewrite workspaces

Restore Postgres into an empty database:

docker compose exec -T postgres psql -U appuser -d appdb < appdb.sql
docker compose exec -T postgres psql -U appuser -d claude_mem < claude_mem.sql

Restore file artifacts before starting the app, or stop the app first:

docker compose stop app
tar -xzf app-rewrite-data.tgz -C /opt/app-rewrite
tar -xzf app-rewrite-workspaces.tgz -C /opt/app-rewrite
docker compose up -d app

Local Developer Example​

On a developer machine, it is acceptable to back container paths with local home directories while keeping container-visible paths under /app:

APP_WORKSPACE_HOST_DIR=/Users/your-user/workspaces
WORKSPACE_BASE_DIR=/app/workspaces/app-rewrite-work-dir

This means the host path:

/Users/your-user/workspaces/app-rewrite-work-dir

appears inside the app container as:

/app/workspaces/app-rewrite-work-dir

Internal Image Build​

For internal development only, build the app image from this repository:

docker compose -f docker-compose.yml -f docker-compose.build.yml up --build

This uses the multi-stage Dockerfile.app. The first stage builds the Go Repave CLI and the final stage copies it to:

/usr/local/bin/repave

This rebuilds only the app image. Four first-party images are built from this repository, and the other three are not touched by the command above:

ImageDockerfileBuild command
app / server (also migrate, ownership-init)Dockerfile.appdocker compose -f docker-compose.yml -f docker-compose.build.yml build app
agent runner (runs every agent job)Dockerfile.agent-runnerdocker build -f Dockerfile.agent-runner -t <AGENT_RUNNER_IMAGE> .
claude-mem server (serves claude-mem-server and claude-mem-worker)Dockerfile.claude-mem-serverdocker build -f Dockerfile.claude-mem-server -t <CLAUDE_MEM_SERVER_IMAGE> .
session base — Node (web IDE sessions)Dockerfile.session-base-nodedocker build -f Dockerfile.session-base-node -t <SESSION_BASE_NODE_IMAGE> .
session base — JavaDockerfile.session-base-javadocker build -f Dockerfile.session-base-java -t <SESSION_BASE_JAVA_IMAGE> .
session base — .NETDockerfile.session-base-dotnetdocker build -f Dockerfile.session-base-dotnet -t <SESSION_BASE_DOTNET_IMAGE> .

A stale sibling image silently reverts behaviour — the classic symptom is "my change isn't taking effect in agent jobs" after a successful app-only rebuild. The image tags must match the AGENT_RUNNER_IMAGE / CLAUDE_MEM_SERVER_IMAGE / SESSION_BASE_*_IMAGE values in .env. The agent runner is launched per job by the app at runtime rather than by Compose, and the app resolves its tag from the AGENT_RUNNER_IMAGE value captured when the app container was created — so after changing that value in .env, recreate the app container for agent jobs to pick up the new runner image.

Also note that up -d app starts only app and its declared dependencies (postgres, migrate, docker-host); the claude-mem services, valkey, and cli-proxy-api are not in app's depends_on and stay down unless you run docker compose up -d with no service argument.

Client production should use a prebuilt image instead — see Update.

Troubleshooting​

Rendered Compose config:

docker compose config

Confirm container workspace paths:

docker compose config | grep WORKSPACE_BASE_DIR
docker compose exec app sh -lc 'mount | grep /app/workspaces'

If agents show host paths such as /Users/... inside Docker, check:

  • .env should not set WORKSPACE_BASE_DIR to a host path.
  • APP_WORKSPACE_HOST_DIR should be the host backing directory.
  • The Compose target should remain /app/workspaces.
  • Recreate the app container after changing .env.
docker compose up -d --force-recreate app

If a job, test run, prototype or web IDE session fails with invalid mount config for type "bind": bind source path does not exist: <path>:

  • The app mounts host folders with docker run --mount type=bind, which refuses a folder that is not there . Older releases used -v, which created the missing folder, empty and owned by root, and carried on.
  • If <path> is a feature worktree or workspace that has since been merged or cleaned up, nothing is wrong: open the workspace again from the chooser. A web IDE session reports this as "This workspace is no longer available".
  • If <path> should exist, the host path mapping is wrong. The daemon checks the host path, so <path> must exist on the Docker host. Check that APP_DATA_HOST_DIR, APP_WORKSPACE_HOST_DIR and any DOCKER_PATH_MAPPINGS entry name the same host folders as the app service's bind mounts in docker compose config.

If UAT starts but the iframe does not load:

  • Check docker compose logs -f app.
  • Confirm generated UAT services bind to UAT_HOST=0.0.0.0.
  • Confirm the app container has host.docker.internal configured.

If Testcontainers cannot connect to Ryuk or mapped ports:

  • Confirm docker compose ps docker-host shows the proxy is running.
  • Confirm DOCKER_HOST=tcp://docker-host:2375 is present in the app container.
  • If DOCKER_AGENT_NETWORK is set, confirm it matches the Compose network that contains docker-host; otherwise Compose should derive it from COMPOSE_PROJECT_NAME.
  • Confirm TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal.

If agent auth fails:

  • Confirm the project has its own anthropicApiKey/openaiApiKey set in Settings — there is no deployment-wide fallback (see the agent-runner design notes §7).
  • Check the project's auth mode in Settings first. ANTHROPIC_DIRECT and VERTEX need no gateway at all; only API_KEY/OAUTH do. If the mode is gateway-routed, confirm cli-proxy-api is running and GATEWAY_BASE_URL/GATEWAY_AUTH_TOKEN/GATEWAY_MANAGEMENT_SECRET are set in .env . Claude agent config uses the app-rewrite-agent-claude Docker volume, auto-created on first agent run.
  • Do not rely on a developer host keychain or browser-login state in client production.

If migrate exits with this container is release X but the deployment is configured for Y:

  • This is a stale one-shot container from the previous release, not a database problem — nothing was checked against the database. It is almost always the result of docker compose up -d --no-recreate after an upgrade.
  • Fix: docker compose rm -f migrate, then docker compose run --rm migrate.
  • The guard reads APP_IMAGE from ./.env, which migrate mounts read-only at /deployment/.env. It deliberately does not use the container's own APP_IMAGE environment variable: env_file: is snapshotted at container creation, so a reused container carries the same stale value as its stale image and the two would agree. A deployment whose docker-compose.yml predates that mount silently has no guard — check for the /deployment/.env volume on the migrate service.
  • Before this guard existed, that stale container validated the already-upgraded database against its own older prisma/schema.prisma and failed the drift gate with a removal-only diff ([-] Removed column ...) that reads exactly like a client database missing recent migrations. If you are reading logs from an older release and see that diff, check the migrate container's image tag before treating it as real drift.

If Postgres data disappears:

  • Check whether docker compose down -v was used.
  • Check the postgres_data named volume.
  • Restore from the logical backup and file artifact backups.

If docker pull/docker compose pull from asia-southeast1-docker.pkg.dev fails intermittently with Unauthenticated request. Unauthenticated requests do not have permission "artifactregistry.repositories.downloadArtifacts", even though gcloud auth print-access-token works and a raw curl against the registry with that token succeeds:

  • Check $DOCKER_CONFIG/config.json (default /opt/app-rewrite/docker-config/config.json) for both an auths entry and a credHelpers entry for the same registry host. A static auths entry (typically added by a one-off docker login -u oauth2accesstoken --password-stdin, rather than the gcloud auth configure-docker flow) embeds a short-lived access token that goes stale, and its presence alongside credHelpers causes Docker to intermittently use the stale static token instead of invoking the gcloud helper for a fresh one.
  • Fix: remove the auths entry for that registry host from config.json, keeping only credHelpers, so every pull goes through the dynamic gcloud helper. Re-run gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet (with DOCKER_CONFIG set to the same directory) afterward to confirm the helper entry is intact.
  • This is a config hygiene issue, not an IAM/quota problem — don't spend time on IAM propagation delays or rate limits before checking for this conflict first.