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
.envvariable, 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 usingDOCKER_HOST_IMAGE.app: the prebuilt Repave image fromAPP_IMAGE.claude-mem-server/claude-mem-worker: the always-on, centralized claude-mem memory server and generation worker (both fromCLAUDE_MEM_SERVER_IMAGE), backed byvalkey(BullMQ queue) and aclaude-memdatabase 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 thecli-proxy-apiCompose profile (COMPOSE_PROFILES=cli-proxy-apiin.env) — see . A new project now defaults toANTHROPIC_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 isVERTEX.- Optional
nginx: TLS-terminating reverse proxy in front ofapp, for deployments exposed directly on a public host, enabled with thenginxCompose 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 savearchives - the session base images every web IDE session container is built from
- optional nginx image, and a
nginx/conf.d/repave.confderived fromnginx/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
.claudeconfig is not a host path. It lives in a shared Docker named volume (AGENT_CLAUDE_CONFIG_VOLUME, defaultapp-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 noCLAUDE_CONFIG_HOST_DIRbind mount. Back it up withdocker 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-serverservice (seedocker-compose.yml) backed by theclaude_memdatabase in the same Postgres container as the app, plus avalkeyqueue 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-memfolder in the workspace anymore. Back up theclaude_memdatabase 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 (
reporead/write,read:userscopes). - 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_KEYencrypts 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:
- Paste the CA that issued the TFS server's certificate, with any intermediates.
- 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.
- 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 tohost.docker.internal:host-gateway. Compose foldsextra_hostsinto a mapping and rejects the whole file on a duplicate hostname, withservices.app.extra_hosts must be a mapping. - A malformed
DOCKER_EXTRA_HOSTSentry fails the job that reads it, by design. The address must be an IPv4/IPv6 literal (orhost-gateway), not another hostname. Silently dropping a typo would leave the original problem in place and surface it hours later as an unrelated failure. Pinninghost.docker.internalis rejected for the same reason: every spawned container already maps it to the host gateway, and/etc/hostsis 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=nginxandAPP_HOST_BIND=127.0.0.1are two separate switches — nothing in Compose enforces that they're set together. Enabling thenginxprofile without also loopback-bindingappleavesappreachable 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_PASSWORDcan 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 apostgres_datavolume locks the app out of its own database with an authentication error. Set it before the deployment's firstup, or leave it alone. Rotating it on a live deployment is a manualALTER ROLEplus an.envedit, 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 .
-
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-8452INTERNAL_API_KEYmust also be set: nginx uses it to ask the platform which app runs on a port. -
Create the gateway config from the tracked example. Replace every
CHANGE_ME: thelistenrange (twice) must equalUAT_GATEWAY_PORTS,server_nameand the certificate paths must matchrepave.conf's:443block,$repave_originis the platform URL reviewers use, and the key isINTERNAL_API_KEY. The copy is gitignored, likerepave.conf.cp nginx/conf.d/uat-gateway.conf.example nginx/conf.d/uat-gateway.conf -
Block the lookup on the public server.
repave.conf.examplenow refuses/api/internal/uat-gateway/on:443; copy thatlocationblock into an existingrepave.conf. -
Publish the range on the nginx service in
docker-compose.override.yml:services:nginx:ports:- "8443-8452:8443-8452" -
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.
-
Restart the app (to read
UAT_GATEWAY_PORTS) and nginx:docker compose up -d --force-recreate --no-deps appdocker 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 withpg_dump -Fcclaude_mem.dump: custom-format PostgreSQL dump of theclaude_memdatabase (claude-mem server-mode memory store), skipped if that database does not exist yet- every compose file the upgrade ran with —
docker-compose.ymlplus the override file when one exists (or, withCOMPOSE_FILEset, each listed file), copied under its basename and listed on the manifest'scomposeFiles=line — and.env - tarballs for
APP_DATA_HOST_DIRandAPP_WORKSPACE_HOST_DIRwhen those paths exist - the
app-rewrite-agent-claudeDocker named volume (Claude agent config and claude-mem plugin runtime, no project memory) — export viadocker 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:
- Edit
.envand set the newAPP_IMAGEtag. - If this deployment needs the CLIProxyAPI gateway and hasn't provisioned it yet (no
GATEWAY_BASE_URLin.envyet — the gateway is disabled by default, ), provision it — the script'sprovision_cli_proxy_configdoes this automatically whencli-proxy-apiis inCOMPOSE_PROFILES, but by hand: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# 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" >> .envgrep -q '^GATEWAY_AUTH_TOKEN=' .env || echo "GATEWAY_AUTH_TOKEN=$(openssl rand -base64 32)" >> .envgrep -q '^GATEWAY_MANAGEMENT_SECRET=' .env || echo "GATEWAY_MANAGEMENT_SECRET=$(openssl rand -base64 32)" >> .envgrep -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 <<EOFhost: ""port: 8317remote-management:allow-remote: truesecret-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: falseEOFGATEWAY_BASE_URLset, or that doesn't need the gateway at all — every project on a gateway-bypassing mode (ANTHROPIC_DIRECT, the default for new projects, orVERTEX). - Pull the image.
- Stop the app so old code is not serving while migrations run.
- Run the one-shot migration service.
- 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:
appdbandclaude_mem(claude-mem server-mode memory store) APP_DATA_HOST_DIRAPP_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:
| Image | Dockerfile | Build command |
|---|---|---|
app / server (also migrate, ownership-init) | Dockerfile.app | docker compose -f docker-compose.yml -f docker-compose.build.yml build app |
| agent runner (runs every agent job) | Dockerfile.agent-runner | docker build -f Dockerfile.agent-runner -t <AGENT_RUNNER_IMAGE> . |
claude-mem server (serves claude-mem-server and claude-mem-worker) | Dockerfile.claude-mem-server | docker build -f Dockerfile.claude-mem-server -t <CLAUDE_MEM_SERVER_IMAGE> . |
| session base — Node (web IDE sessions) | Dockerfile.session-base-node | docker build -f Dockerfile.session-base-node -t <SESSION_BASE_NODE_IMAGE> . |
| session base — Java | Dockerfile.session-base-java | docker build -f Dockerfile.session-base-java -t <SESSION_BASE_JAVA_IMAGE> . |
| session base — .NET | Dockerfile.session-base-dotnet | docker 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:
.envshould not setWORKSPACE_BASE_DIRto a host path.APP_WORKSPACE_HOST_DIRshould 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 thatAPP_DATA_HOST_DIR,APP_WORKSPACE_HOST_DIRand anyDOCKER_PATH_MAPPINGSentry name the same host folders as theappservice's bind mounts indocker 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.internalconfigured.
If Testcontainers cannot connect to Ryuk or mapped ports:
- Confirm
docker compose ps docker-hostshows the proxy is running. - Confirm
DOCKER_HOST=tcp://docker-host:2375is present in the app container. - If
DOCKER_AGENT_NETWORKis set, confirm it matches the Compose network that containsdocker-host; otherwise Compose should derive it fromCOMPOSE_PROJECT_NAME. - Confirm
TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal.
If agent auth fails:
- Confirm the project has its own
anthropicApiKey/openaiApiKeyset 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_DIRECTandVERTEXneed no gateway at all; onlyAPI_KEY/OAUTHdo. If the mode is gateway-routed, confirmcli-proxy-apiis running andGATEWAY_BASE_URL/GATEWAY_AUTH_TOKEN/GATEWAY_MANAGEMENT_SECRETare set in.env. Claude agent config uses theapp-rewrite-agent-claudeDocker 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-recreateafter an upgrade. - Fix:
docker compose rm -f migrate, thendocker compose run --rm migrate. - The guard reads
APP_IMAGEfrom./.env, whichmigratemounts read-only at/deployment/.env. It deliberately does not use the container's ownAPP_IMAGEenvironment 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 whosedocker-compose.ymlpredates that mount silently has no guard — check for the/deployment/.envvolume on themigrateservice. - Before this guard existed, that stale container validated the already-upgraded database against its own older
prisma/schema.prismaand 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 -vwas used. - Check the
postgres_datanamed 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 anauthsentry and acredHelpersentry for the same registry host. A staticauthsentry (typically added by a one-offdocker login -u oauth2accesstoken --password-stdin, rather than thegcloud auth configure-dockerflow) embeds a short-lived access token that goes stale, and its presence alongsidecredHelperscauses Docker to intermittently use the stale static token instead of invoking thegcloudhelper for a fresh one. - Fix: remove the
authsentry for that registry host fromconfig.json, keeping onlycredHelpers, so every pull goes through the dynamicgcloudhelper. Re-rungcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet(withDOCKER_CONFIGset 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.