Skip to main content
Version: 0.1.125 – latest

Install with Docker Compose

Everything an operator needs to install, verify, upgrade, troubleshoot, and remove a Repave deployment — without access to the application source.

This is the single entry point. Two companions:


1. Host requirements​

  • Linux host or VM, x86-64
  • Docker Engine + Docker Compose v2 (docker compose)
  • Persistent disk for database, runtime artifacts, and project workspaces
  • 8 GB RAM minimum; 20 GB free disk minimum, 50 GB+ recommended
  • Network access to the release registry (online installs only)

No other host tooling is required. The installer carries its own jq, openssl, curl, and Docker CLI, so host versions cannot affect the result.

If Docker is not installed yet, run the bootstrap script — shipped in the release bundle's scripts/ directory (download the bundle from the release page or GCS; it needs no Docker to unpack):

tar -xzf legacy-modernization-<version>-online.tgz
sudo legacy-modernization-<version>-online/scripts/bootstrap-docker.sh --data-root /data/docker

--data-root places Docker's storage on a larger volume, and must be set before the daemon first starts — relocating a populated data-root later is a stop-the-daemon operation. Skip the flag to use the default. Run --check-only first to see what it would do.


2. Install​

Authenticate once​

sudo mkdir -p /opt/app-rewrite/installer-docker-config
sudo docker --config /opt/app-rewrite/installer-docker-config login -u _json_key --password-stdin \
asia-southeast1-docker.pkg.dev < /path/to/artifact-reader.json

Use the per-client read-only service-account key, not gcloud auth login — human credentials expire and can only be refreshed interactively, which has stranded a deploy mid-upgrade before.

Log in with --config pointed at a directory under the install root, not your own ~/.docker. The installer runs in its own container — a filesystem separate from the host's — so a plain docker login writes credentials the installer can never see, and every pull then fails Unauthenticated request even though the login itself reported success. Logging in with --config /opt/app-rewrite/installer-docker-config and mounting that same directory into the installer (below) puts both sides on the same credentials. preflight checks for this mount and fails clearly if it is missing, rather than letting install discover it mid-pull.

This is a separate directory from docker-config/, which deploy-client-release.sh and the onboard-client-registry-auth runbook use for a different mechanism: a gcloud-managed credHelpers entry that fetches a fresh token on every pull. The installer image carries no gcloud (dropping it is most of how it stays under 200 MB), so it cannot use a credential helper at all — a static, non-expiring docker login is the only option available to it. Writing that static credential into the same directory as an existing credHelpers entry is a real, previously-hit failure mode: a static auths entry alongside credHelpers makes Docker intermittently use the stale entry instead of invoking the helper. Keeping the two directories distinct means a host that uses both mechanisms — a credential helper for its own scripts, and this installer for upgrades — cannot reproduce that incident, because the installer never writes into docker-config/ at all.

Run the installer​

export INSTALLER=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/installer:<version>

sudo mkdir -p /opt/app-rewrite
sudo docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
-v /opt/app-rewrite/installer-docker-config:/root/.docker \
"$INSTALLER" install

The installer creates the directory layout, generates secrets, pins image tags, sets data ownership, pulls images, runs migrations, starts the stack, and verifies the result.

It is idempotent — if it fails partway, fix the cause and run the same command again. It resumes rather than requiring cleanup, and never regenerates existing secrets or discards your .env edits.

Commands​

All use the same image and the socket + install-root mounts. The credentials mount is needed by install and upgrade — the commands that actually pull images — and preflight checks that it is present and usable:

CommandPurposeNeeds credentials mount
preflightCheck host readiness. Read-only — safe at any time, including against a live deployment.yes (checks it's there)
installInstall, or resume a failed install.yes
upgradeUpgrade an existing deployment to this image's version.yes
verifyConfirm the deployment actually works.no
doctorPrint diagnostics for a misbehaving deployment.no
uninstallTear down. Preserves data unless --delete-data.no

Options​

OptionMeaning
--install-root <path>Deploy elsewhere than /opt/app-rewrite. Change both sides of the -v to match.
--airgapNever contact a registry; images must be preloaded.
--data-root <path>Report-only check that Docker's storage is where you expect.
--health-url <url>Non-default health endpoint.
--yesSkip confirmation prompts (required for non-interactive runs).
--stop-running-containersStop the open code-server (VS Code) session containers a deploy has to drain, instead of waiting for them. An interactive run offers this at a prompt; this flag is how an unattended one opts in. --yes does not imply it. Job containers (agents, BDD/unit-test runs) need no flag: they are always stopped, and their jobs resume on their own.
--delete-datauninstall only — also remove data, workspaces, and volumes.

Why the mount uses the same path on both sides. The app launches sibling containers through the Docker socket, and the daemon resolves their mount paths on the host, not inside the app container — so .env must record host paths. Mounting at a matching path means every path is valid on both sides, with nothing to translate. The installer refuses to run if the install root is not a real mount point, because Docker silently creates a missing -v source and a forgotten mount would otherwise write a whole deployment into a throwaway container filesystem and report success.

To deploy elsewhere, change both sides: -v /srv/app-rewrite:/srv/app-rewrite ... install --install-root /srv/app-rewrite

Airgapped hosts​

docker load -i installer-<version>.tar
docker load -i app-images-<version>.tar
sudo docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
"$INSTALLER" install --airgap

--airgap lists anything missing up front rather than failing partway through. That covers every image the release needs offline, not only the ones Compose starts: the web IDE's session-base-* and dind images are checked too, because they are consumed later when a session container is built and this host has no registry to fall back on.

Five third-party images (postgres, valkey, the docker-host sidecar, cli-proxy-api, nginx) are not shipped in the bundle at all — you are expected to preload each yourself, the same way you would any image this installer doesn't ship. --airgap only checks each is present and reports by name any that are missing; it never pulls on your behalf. postgres and valkey are required to run the stack, so a fully offline install needs a manual docker pull (if this host can reach their registries) or docker load from an archive obtained separately; nginx/cli-proxy-api only matter once you enable their Compose profile, but the same requirement applies then.

See airgapped-install.md for bundle contents.

After installing​

Open the URL, import the signed customer license, then sign in.

If the import fails, the message tells you which side the problem is on:

MessageMeaning
"This license is invalid. Check the license file and try again."The file is not valid JSON, is missing fields, or was signed by a key this release does not trust. Check it survived the transfer intact.
"The license signature is invalid. Import a valid Repave license."The signature does not match the payload — the file was altered after signing.
"The license could not be saved because of a server error…"The license file is fine. Something behind it failed; the cause is in docker compose logs app.
"Could not reach the server…"The request never arrived — the app is down, or a proxy answered instead of it.

The license is never partially applied: if the import reports an error, no license was recorded, so it is always safe to fix the cause and import the same file again.

Every project needs its own API key in Settings — there is no deployment-wide fallback. Saving a project's key registers it with the CLIProxyAPI gateway automatically.


3. Verify​

verify never pulls, so it doesn't need the credentials mount:

sudo docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
"$INSTALLER" verify

Checks real behaviour, not just that containers are up:

  • Health endpoint returns ok and the expected version — a stale container answering happily is exactly what this catches
  • Postgres accepting connections
  • repave CLI usable inside the app container
  • Agent-runner image present locally (absent, agent jobs fail at run time, long after install looks successful)
  • CLIProxyAPI gateway listening
  • data/ and workspaces/ fully owned by the app image's uid
  • Where nginx is configured: health through the proxy, since a recreated app container gets a new IP that nginx caches while the direct check looks clean

4. Upgrade​

export INSTALLER=<registry>/installer:<new-version>
sudo docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
-v /opt/app-rewrite/installer-docker-config:/root/.docker \
"$INSTALLER" upgrade

First time using the installer on this host — e.g. the deployment was installed by an earlier release's manual procedure or the host-side scripts? Do §2's "Authenticate once" first: without installer-docker-config/ populated and mounted, preflight stops the upgrade at the credentials check.

What happens, in order: preflight → backup (database dump, .env, compose file, bind-mounted directories) → refresh deployment assets → pull images → align data ownership with the new image → run migrations → recreate containers → verify.

Two things worth knowing:

  • Ownership is self-migrating. The target uid is read from the app image rather than hardcoded, so the same step migrates forward on upgrade and reverses cleanly if you roll back to an older image. No manual chown, no version gate.

  • docker-compose.override.yml is never touched. Client-specific additions (TLS, custom domains) belong there and survive upgrades. The release owns docker-compose.yml and overwrites it.

  • .env follows current/. Every run aligns .env's owner with the owner of the deploy directory, so on a host set up by install-repave-user.sh it belongs to the operator even though the installer itself runs as root. That matters because .env is 0600 and Compose reads it for interpolation: a root-owned .env makes every later docker compose ps|logs|restart fail with permission denied for an operator who is in the docker group but not a sudoer. A deployment already in that state — installed before this fix — is repaired by the next install or upgrade. The mode itself is not preserved: install re-asserts 0600, so widening .env to 0640 for a group is not a durable change; chown current/ to the operator instead.

  • Running containers are drained first. Agent jobs, BDD/unit-test runs and open VS Code sessions write to the data the upgrade is about to re-own, so they have to be gone before it proceeds.

    • Agent jobs are stopped without asking, and carry on by themselves. The upgrade stops the app first, then the job containers. Each interrupted job goes back to the queue and, when the new app starts, continues where it stopped in the same agent session. Its agent log says so: "Interrupted by a platform restart … It resumes automatically in the same session (automatic resume 1 of 3)." A job interrupted a fourth time in a row is left FAILED for someone to resume by hand. A host reboot or a docker stop of a job container is handled the same way.
    • Open VS Code sessions need your say. Stopping one closes that web IDE and loses unsaved editor state, so the upgrade lists them — while the app is still serving — and asks. Answer n to wait instead (5 minutes, then the upgrade aborts and rolls back); a session never ends on its own, so that only ever times out. Unattended runs need --stop-running-containers to stop them.

Backups land in backups/pre-upgrade/<timestamp>/. Keep the most recent until you have confirmed the upgrade in the browser, not just via the health check.


5. Troubleshooting​

Start with (doctor reports, never pulls, so it doesn't need the credentials mount either):

sudo docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
"$INSTALLER" doctor

doctor reports versions, disk, compose state, image presence, health, data ownership, gateway credential registration, and recent errors. It never prints .env values, so its output is safe to paste into a support thread.

Symptom map​

Agent jobs fail with unknown provider for model proj-<id>/<model>, or 502 from the gateway Restarting or recreating cli-proxy-api wipes every registered per-project credential. The app re-registers them by itself when it starts, and again before dispatching a job the gateway has forgotten, so this should heal without help — check docker compose logs app | grep GatewayCredentials first. If the reconcile itself is failing (unreachable gateway, mismatched GATEWAY_MANAGEMENT_SECRET), re-save each affected project's Settings to re-register. doctor compares registered credentials against projects that should have them.

upgrade fails with N managed container(s) still running after 300s Something the app launched is still holding the data — almost always an open VS Code session, which the upgrade will not close without consent. If you declined, or the run was unattended, re-run with --stop-running-containers; it closes that web IDE and loses unsaved editor state, so check with whoever is using it first. A job container listed here ignored its stop; its job resumes once the container is gone.

Uploads or agent jobs fail with EACCES Data ownership does not match the app image's uid. Re-running upgrade fixes this automatically. To inspect without changing anything: sudo scripts/patch-repave-ownership.sh; add APPLY=1 to fix in place.

The app tries to build app-rewrite-agent-runner:latest AGENT_RUNNER_IMAGE is unset or the image was never pulled. verify catches this; re-run install/upgrade to repin and pull.

Runner logs repeatedly show POST ... fetch failed Set DOCKER_AGENT_NETWORK to the Compose network and DOCKER_HOST_URL to http://app:3000, then recreate the app container.

Everything healthy on localhost:3000, but real traffic returns 502 nginx is holding the previous app container's IP. Restart nginx. verify detects this when nginx is a defined service.

Large uploads fail at nginx Set client_max_body_size 11g in the nginx config and reload it.

It must sit above the application's own upload cap (MAX_UPLOAD_BYTES, 10 GB), not equal to it: nginx measures the whole request body while the application caps the uploaded file alone, so a 10 GB file arrives as slightly more than 10 GB. Set them equal and nginx always refuses first, with a generic HTML error instead of the application's message naming the file. Set it lower and that lower number silently becomes your real limit, whatever the application tells users.

nginx buffers request bodies to disk. They go to the nginx_body_temp volume, so the space is explicit and relocatable, but a named volume still sits under Docker's data-root by default — the disk whose exhaustion wedges the daemon. Size that disk for client_max_body_size × the concurrent uploads you expect, or point the volume at another disk; see docker-compose-deployment.md.

Upgrading an existing deployment: the live vhost config under nginx/conf.d/ is per-deployment and not tracked in git, so a release upgrade will not change it. An install that predates this setting still enforces its old client_max_body_size — commonly 2g — while the application now tells users the limit is 10 GB. Edit the file and reload nginx (docker compose exec nginx nginx -s reload) as part of the upgrade, or the lower value stays your real limit. Add client_body_temp_path /var/cache/nginx/client_temp; at the same time: without it nginx uses its compiled-in default, which happens to match the mounted volume for the nginx:1.27-alpine image this ships with, but not for every build — nginx-unprivileged and several distro packages default to /var/lib/nginx/..., where nothing is mounted and buffered bodies land back on Docker's data-root.

HTTPS does not work Confirm the Docker nginx service owns ports 80/443, certificates are mounted read-only, and any host nginx is stopped.

Image pulls fail with Unauthenticated request Using the installer: almost always the installer-docker-config mount is missing, or the login used to populate it wasn't done with --config pointed at that directory — docker login on the bare host writes to the host's own ~/.docker/config.json, a different filesystem from the installer container, so a successful-looking login there does nothing for the installer's own pulls. preflight catches this before install starts; re-run it if you skipped straight to install. See §2's "Authenticate once" for the exact commands.

Using the host-side scripts directly (deploy-client-release.sh and similar): usually a stale static auths entry conflicting with the gcloud credential helper in docker-config/config.json — not an IAM problem. See docker-compose-deployment.md.

A pull fails with no space left on device Docker's data-root filled. preflight checks this in advance. See bootstrap-docker.sh --data-root and the relocation notes in the deployment reference.

exec format error when running the installer Wrong architecture for this host. Use the release image built for x86-64.


6. Backup and restore​

Upgrades back up automatically. For an on-demand backup, and for the full restore procedure, see docker-compose-deployment.md — the database dump plus .env, compose file, and bind-mounted directories are what a restore needs.


7. Uninstall​

Like verify and doctor, uninstall never pulls an image, so it does not need the credentials mount:

# Stop and remove containers; ALL DATA PRESERVED
sudo docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
"$INSTALLER" uninstall

# Also delete data, workspaces, and volumes — irreversible
sudo docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/app-rewrite:/opt/app-rewrite \
"$INSTALLER" uninstall --delete-data

Without --delete-data, data is left in place and a later install adopts it. With it, the installer lists exactly what will be destroyed and requires confirmation; backups/ is never removed.


8. Manual installation​

The installer automates the procedure documented in docker-compose-deployment.md, which remains supported and is the reference for anything the installer does not cover.

The manual path in brief — unpack the release bundle, cp .env.example .env, set NEXTAUTH_SECRET/INTERNAL_API_KEY (openssl rand -base64 32), set image tags from metadata/images.json, set APP_DATA_HOST_DIR/ APP_WORKSPACE_HOST_DIR to host paths, create those directories owned by the image's repave uid (10001), then:

docker compose pull
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 --no-deps app
curl -fsS http://localhost:3000/api/health

On a fresh install the migration service bootstraps the database from the packaged schema and records the packaged migrations as applied, leaving future upgrades on normal Prisma migration history. Do not substitute prisma db push.

Older pilot databases created by schema push must be baselined once — see the PRISMA_BASELINE_EXISTING_SCHEMA flow in the deployment reference.

Historical note: earlier releases created a dedicated host repave user at uid 1000. That is no longer needed — the images use uid 10001, which no cloud image's login user occupies, so container-writable directories are simply owned by that numeric uid and ls -l showing a bare number is expected.