Skip to main content
Version: 0.1.124

Installing without internet access

This guide describes how to install a Legacy Modernization release on a host that cannot pull container images from an external registry.

Choose A Release Artifact​

Each release publishes two airgapped bundles:

legacy-modernization-<version>-airgap-core.tgz
legacy-modernization-<version>-airgap-core.tgz.sha256
legacy-modernization-<version>-airgap-session-bases.tgz
legacy-modernization-<version>-airgap-session-bases.tgz.sha256

Core is required. Session-bases is required only if you will use the web IDE.

They were one file until 0.1.84. A single bundle ran 9–11 GB, and scp — the usual way it reaches an airgapped host — cannot resume, so any interruption discarded the whole transfer and started over. The three web IDE session base images are the bulk of that size, and a deployment uses at most one of the three (see below), so they now travel separately.

The old undifferentiated legacy-modernization-<version>-airgap.tgz no longer exists. That is deliberate: reusing the name for the core bundle would silently hand an operator following an older runbook a deployment with no web IDE.

Core bundle​

The Compose files, install docs, image metadata, checksums, and the first-party images the running stack needs:

images/server-<version>.tar.gz
images/agent-runner-<version>.tar.gz
images/claude-mem-server-<version>.tar.gz
images/dind-<version>.tar.gz
images/installer-<version>.tar.gz

installer is not part of the running stack — it's what you run to install and upgrade — but ships here for the same reason everything else in this list does: this host cannot pull an image later.

dind is the private Docker daemon each web IDE session runs. It stays in core because it is small next to a session base, and the installer requires it.

Third-party images: not in the bundle​

Five images this release depends on are not archived in either bundle: docker-host (alpine/socat), postgres, valkey, cli-proxy-api, and nginx. These are unmodified upstream images we don't build, and as of the release pipeline no longer ships a copy of them — it still pins and records their exact tag+digest in metadata/images.json (with bundled: false) and metadata/release-manifest.json, so you can see precisely what this release expects, but the archive itself is not in images/.

We assume you preload these five images yourself, the same way you would any image this installer doesn't ship. From a connected host, pull the exact tag+digest recorded in metadata/images.json, docker save it, and load it on the target host before installing — or, if the host has some network route to the images' upstream registries (docker.io, ghcr.io, wherever cli-proxy-api's image resolves from), docker pull them there directly. postgres and valkey are not optional — the stack cannot start without them — so this applies to a from-scratch airgapped install of the core stack itself, not only to the nginx/cli-proxy-api profiles.

If you previously relied on the old all-in-one bundle to install with zero preload steps at all, that guarantee no longer holds for these five images. The installer's --airgap preflight checks each by name and tells you which ones are missing — it never attempts a pull on your behalf.

Session-bases bundle​

images/session-base-node-<version>.tar.gz
images/session-base-java-<version>.tar.gz
images/session-base-dotnet-<version>.tar.gz

These are what the web IDE needs offline. Nothing is built when a session opens: these are the finished session images, and a session runs one of them directly, alongside its own Docker daemon from the core bundle's dind image.

A deployment uses at most one of the three. The image is chosen per project from its modernized stack — a Next.js or Node target opens the Node base, Spring Boot the Java base, .NET the .NET base. For a codebase you supplied yourself, the toolchain is detected from its build manifests (a pom.xml/build.gradle means Java, a .csproj/.sln means .NET, otherwise Node), and can be overridden in project settings. The Java and .NET bases each ship Node as well, so a JS frontend beside a Java or .NET backend needs only that one image.

Take this bundle if you will use the web IDE. You can also take it later — the core install does not depend on it, and loading these images afterwards needs no reinstall.

Installing without the session bases​

Core installs and runs on its own: the stack starts and every agent job runs. Only the web IDE is affected. The installer's --airgap preflight reports each absent session base as a warning naming this bundle, and does not fail. If someone then opens a session for a stack whose base is not loaded, the web IDE refuses with the missing tag and the .env key that pins it — there is no build to wait for and nothing to retry.

That is why they are large — each carries the full IDE runtime (code-server, the Claude Code extension and CLI, a Playwright browser, the repave CLI) on top of its toolchain, so expect roughly 1.5 GB more across the three than earlier releases. All of it used to be downloaded on the client, on each project's first "Open in VS Code", from github.com, open-vsx.org, the Playwright CDN, npm and apt — five hosts an airgapped install cannot reach, which made the web IDE unusable here at all. It is baked in at publish time instead.

The toolchains are baked in for the same reason: upstream devcontainer features resolve from ghcr.io and then download runtimes from nodejs.org and SDKMAN, so the generated devcontainer config requests no remote features either.

If one of these images is not loaded, the web IDE says so and names both the missing tag and the .env key that pins it — there is no build to wait for and nothing to retry.

When the client's codebase needs an older toolchain​

These images carry one version per stack — .NET 10, JDK 21, Node 20. A provided codebase that targets an older one (Angular 14, .NET 6) will not build in them, and no .env setting changes that. The answer is a per-client overlay image: a thin derivation of the published base that adds the legacy toolchain beside the shipped one, built on a connected host and loaded here like any other archive. Repave builds the overlay for your deployment and supplies it with the release. Note that an overlay must be rebuilt and loaded here for every new version. The pin itself follows the upgrade on its own, from the <KEY>_OVERLAY declaration in .env; an upgrade whose overlay image is missing stops and names the tag rather than reverting the deployment to the stock image .

Prepare The Host​

The host needs Docker Engine or Docker Desktop and Docker Compose v2 available as docker compose.

Create persistent directories:

sudo mkdir -p /opt/legacy-modernization/{data,workspaces}
sudo chown -R 10001:10001 /opt/legacy-modernization/{data,workspaces}

The app and agent runner containers run as uid/gid 10001 (the image's repave user), so these directories must be owned by uid 10001 on the host. No host account with that uid is needed — the containers only care about the number, and ls -l showing the bare number is expected. Agent .claude config lives in a Docker named volume, so no claude/ host directory is needed. For the fuller directory layout, see the operator runbook and scripts/install-repave-user.sh.

Copy the core bundle and its checksum file to the host, then verify and extract it. Each bundle verifies on its own — you do not need both present to check either:

cd /opt/legacy-modernization
shasum -a 256 -c legacy-modernization-<version>-airgap-core.tgz.sha256
tar -xzf legacy-modernization-<version>-airgap-core.tgz
cd legacy-modernization-<version>-airgap-core
shasum -a 256 -c SHA256SUMS

SHA256SUMS covers only that bundle's own files, so a core bundle passes its check with no session base present. Use the same three commands for the session-bases bundle, substituting its name.

Run the rest of this guide from the extracted core directory. If you are also installing the session bases, extract that bundle too and copy its images/ archives alongside core's, so the docker load commands below find everything in one place:

tar -xzf legacy-modernization-<version>-airgap-session-bases.tgz
cp legacy-modernization-<version>-airgap-session-bases/images/*.tar.gz \
legacy-modernization-<version>-airgap-core/images/

Load Images​

Load every archive in the core bundle before running Compose:

docker load -i images/server-<version>.tar.gz
docker load -i images/agent-runner-<version>.tar.gz
docker load -i images/claude-mem-server-<version>.tar.gz
docker load -i images/dind-<version>.tar.gz
docker load -i images/installer-<version>.tar.gz

If you are installing the web IDE, also load the session bases. Loading only the one your projects need is fine and saves the disk the others would occupy:

docker load -i images/session-base-node-<version>.tar.gz
docker load -i images/session-base-java-<version>.tar.gz
docker load -i images/session-base-dotnet-<version>.tar.gz

The five third-party images (docker-host, postgres, valkey, cli-proxy-api, nginx) have no archive to load — see Third-party images: not in the bundle above. Preload them yourself before continuing: docker pull the exact tag pinned in metadata/images.json directly if this host has network access to their upstream registries, or pull and docker save them on a connected host first and docker load the result here. The installer's preflight only checks each is present — it never pulls on your behalf.

Confirm the required images are available:

docker image inspect \
"$(node -e "console.log(require('./metadata/images.json').images.server.tag)")" \
"$(node -e "console.log(require('./metadata/images.json').images.agentRunner.tag)")" \
"$(node -e "console.log(require('./metadata/images.json').images.claudeMemServer.tag)")" \
"$(node -e "console.log(require('./metadata/images.json').images.dind.tag)")"

Then, for each session base you loaded:

docker image inspect \
"$(node -e "console.log(require('./metadata/images.json').images.sessionBaseDotnet.tag)")"

metadata/images.json lists every image in the release, not only the ones in the bundle you extracted — metadata/release-manifest.json's includedImages is what names the archives this particular bundle carries, and each image's bundled flag tells you whether it has an archive at all.

Configure .env​

Create .env:

cp .env.example .env

Set image tags to the tags loaded from the archives. Use metadata/images.json as the source of truth:

APP_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/server:<version>
AGENT_RUNNER_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/agent-runner:<version>
CLAUDE_MEM_SERVER_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/claude-mem-server:<version>
# These five are third-party images not shipped in either bundle
# — the tag below is what this release pins; the host must be able to `docker
# pull` it (or you must `docker load` a matching archive obtained separately)
# before Compose can start the corresponding service.
DOCKER_HOST_IMAGE=alpine/socat:1.8.1.3
POSTGRES_IMAGE=postgres:16-alpine3.24
VALKEY_IMAGE=valkey/valkey:8-alpine
CLI_PROXY_IMAGE=eceasy/cli-proxy-api:v7.2.143
# Web IDE session images. These ARE the session images: a session runs one of
# them directly, building nothing. Leave all three set even if you loaded only
# one archive — the value is the tag a session looks for, and an unloaded one
# simply means the web IDE refuses that stack by name. They ship in the
# separate -airgap-session-bases bundle.
SESSION_BASE_NODE_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/session-base-node:<version>
SESSION_BASE_JAVA_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/session-base-java:<version>
SESSION_BASE_DOTNET_IMAGE=asia-southeast1-docker.pkg.dev/repave-prod/legacy-modernization/session-base-dotnet:<version>
CODE_SERVER_DIND_IMAGE=docker:28-dind
# TLS reverse proxy. Only started when the `nginx` Compose profile is
# active. Also a third-party image not shipped in the bundle —
# this host must be able to reach it, or you must load it manually, before
# enabling the profile.
NGINX_IMAGE=nginx:1.27-alpine
APP_PULL_POLICY=missing
DOCKER_HOST_PULL_POLICY=missing

Do not use APP_PULL_POLICY=always in an airgapped environment.

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/legacy-modernization/data
APP_WORKSPACE_HOST_DIR=/opt/legacy-modernization/workspaces

Every project must have its own anthropicApiKey/openaiApiKey set in Settings before agent jobs can run — there is no deployment-wide fallback (see the agent-runner design notes §7).

Set the public URL for the deployment:

REPAVE_PUBLIC_URL=http://localhost:3000

For a reverse-proxied deployment, use the browser-facing URL instead of localhost.

Paste the bare URL. Chat and mail clients auto-link URLs by wrapping them in angle brackets, and copying that form into .env gives you REPAVE_PUBLIC_URL=<https://repave.example.com>, which is not a valid URL. The app rejects it at startup and names the variable. This is the single most common hand-editing mistake in an airgapped install.

NEXTAUTH_URL, NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_BASE_URL and REPAVE_API_BASE_URL are deprecated aliases. They still work so existing deployments upgrade cleanly, but each logs a warning at startup and will be removed in a future release.

Start​

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

Expected response:

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

There are no optional service profiles to start. The web IDE brings up its own per-session containers on demand from the preloaded session-base-* and dind images.

Verify Runtime Tools​

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

Configure Package Registries​

Agent jobs install the project's own dependencies while they work — npm install during a BDD run, a UAT start, or a user-journey run. With no configuration those resolve from npmjs.org, Maven Central, and the NuGet Gallery, which an airgapped host cannot reach. Point them at your internal repository instead.

This is configured per project, in the UI, not in .env: the URLs and credentials live in the database so they can be entered, tested, and rotated without a redeploy.

  1. Open the project, then Settings → Integrations → Package Registries.
  2. For each ecosystem the project uses, press Configure and fill in:
    • Registry URL — the full URL, e.g. https://nexus.corp.example.com/repository/npm-group/. Do not put a username or password in the URL; the save is rejected if you do.
    • Authentication — None, Token, or Username and password.
  3. If your repository presents a certificate from an internal CA, paste the PEM chain into Custom CA certificate. One certificate covers every toolchain.
  4. If reaching it requires the corporate proxy, set HTTP proxy. Without credentials — authenticated proxies are not supported.
  5. Press Save, then Test connection.

Test connection runs a real authenticated request from a throwaway container with the same network access agent jobs get, and reports one line per registry: reachable, an authentication failure, a name that will not resolve, a refused connection, or an untrusted certificate. Fix it here, before a job depends on it.

Settings apply to new agent jobs and new IDE sessions. A job already running keeps the configuration it started with.

What a job receives​

Only the ecosystems you configured. Nothing is exported for one you left alone, so an unconfigured ecosystem behaves exactly as it does today.

ConfiguredThe job gets
npm/etc/repave/registry-config/npmrc, with NPM_CONFIG_USERCONFIG pointing at it
Maven~/.m2/settings.xml (mirrors * through your repository)
Maven~/.gradle/init.gradle as well — Gradle reads no Maven config, and Spring projects may use it
NuGet~/.nuget/NuGet/NuGet.Config
CA certificateca-bundle.pem, exported as NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and GIT_SSL_CAINFO
HTTP proxyHTTP_PROXY/HTTPS_PROXY in both cases, with localhost, 127.0.0.1 and host.docker.internal always excluded

All of it is mounted read-only and removed when the job finishes.

BDD test runs​

A BDD suite runs in a container of its own, not in the agent container that implemented the feature. In earlier releases that container received none of this configuration, so on an airgapped host implementation succeeded and the pre-PR full-suite gate then failed at dependency resolution:

error NU1301: Unable to load the service index for source https://api.nuget.org/v3/index.json
error NU1301: Network unreachable (api.nuget.org:443)

It now receives what an agent job receives, plus the container-image settings from "Container Images" below. Both halves matter: a BDD run starts Testcontainers databases, and while the daemon that pulls them is configured by AGENT_DIND_REGISTRY_MIRROR/AGENT_DIND_REGISTRY_CA_DIR, the image name is chosen inside this container by TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX. Configure the daemon but not the name and the pull still goes to Docker Hub.

Two prerequisites are specific to .NET projects, and both fail after the registry is correctly configured — so they read as new problems rather than as setup:

  • The feed must be HTTPS. .NET 10 refuses an http:// package source outright ("NuGet requires HTTPS sources"), and the generated NuGet.Config does not set allowInsecureConnections.
  • The feed must also serve the .NET targeting packs. The generated config begins with <clear />, so nuget.org is not a fallback for anything. A project targeting net6.0 restores Microsoft.NETCore.App.Ref and Microsoft.AspNetCore.App.Ref at that version from your repository, and those are not bundled with the SDK the agent-runner image ships. A proxy or group repository in front of nuget.org covers this; a hosted-only repository does not, and fails with NU1101/NU1102 naming the missing package rather than the feed.

Two more affect speed rather than success:

  • Use the feed's NuGet V3 URL, ending in /index.json. A URL without it makes NuGet speak V2, where every package lookup returns the XML for every version ever published. Through a Nexus proxy that cost 1.3–5.2 minutes per project on a full restore. In Nexus the V3 endpoint needs a proxy repository whose protocol version is V3, with remote https://api.nuget.org/v3/index.json; a V2 proxy answers /index.json with an error.
  • Keep the workspace volume on a local disk. Job containers in a project share one NuGet packages folder under <workspace>/<project>/.repave/cache/nuget/, which is what lets a repeat BDD run skip dotnet restore entirely. Containers coordinate writes to it with file locks, which local filesystems honour across containers. On NFS, SMB/CIFS or a VM file-sharing mount they may not, and concurrent restores can then corrupt packages. See .

Project scaffolding​

Creating a project scaffolds the modernized codebase — npm create vite, create-next-app, npm install — and that runs inside the app container rather than an agent container. It reads the same npm registry, CA certificate and proxy settings from the card above, staged on the app's own filesystem and removed when the scaffold finishes.

Configure the registry before creating the project. A project scaffolded first will fail with

MODERNIZED_INIT job in project "<name>" failed:
Error: React frontend initialization failed with code 1

which is npm create vite failing to reach npmjs.org. Configure the registry and re-trigger scaffolding from project settings.

Maven, Gradle and NuGet configuration does not apply to scaffolding — those toolchains have no environment variable naming a config file, and scaffolding resolves no packages from them (dotnet new uses templates shipped in the SDK). Agent jobs still get all three. If you configured one, the app log names the files it skipped.

One thing no registry setting can fix: a Spring Boot backend target calls start.spring.io to generate the project, so that target cannot be scaffolded in an airgapped install.

Deployment-wide proxy​

A project that names no proxy of its own inherits HTTP_PROXY from the app container's environment (see .env.example). A project that names one overrides it. Add internal hosts that must bypass the proxy to NO_PROXY; they are merged with the three above, not replaced.

Web IDE sessions​

A session gets the same files at the same moment, mounted under the session's home (/tmp/repave-home) rather than the agent runner's. A developer running npm install in the session terminal resolves from your repository exactly as an agent job does. Configuration is read when the session starts, so change it and reopen the session rather than expecting a running one to pick it up.

If you have no registry of your own, enable the registry Compose profile and Repave runs one for you:

COMPOSE_PROFILES=cli-proxy-api,registry

Preload the registry image first. Like every third-party image, registry:2 is not in the airgap bundle — and it is the one image this host cannot obtain from the registry the profile exists to provide, so carry it in before you enable the profile:

# connected host
docker pull registry:2 && docker save registry:2 -o registry-image.tar
# airgapped host
docker load -i registry-image.tar

The installer's airgap preflight warns while the profile is off and fails the install once it is on, so a missing image is reported before it can surprise you. Set REGISTRY_IMAGE in .env if you preloaded a different tag.

That single line also defaults AGENT_DIND_REGISTRY_MIRROR, AGENT_DIND_INSECURE_REGISTRY and UAT_IMAGE_REGISTRY_PREFIX to the service's address, so there is nothing else to configure. Anything you set explicitly always wins, so a deployment already pointing at a corporate Nexus is unaffected.

Why it needs one manual step. Repave pulls images on three daemons:

DaemonServesReaches the registry by
Host daemonUAT stacks, prototype databasespublished port, via an /etc/hosts entry
Per-job agent dindagent job workloadsCompose network alias
Per-session IDE dindweb IDE workloadsCompose network alias

The two dinds are joined to the registry's network automatically. The host daemon belongs to no Docker network, so it needs the hosts entry — and without it you get the confusing half-working state where agent jobs pull fine while UAT and prototype databases fail:

sudo scripts/bootstrap-docker.sh --bundled-registry
# or, equivalently:
echo "127.0.0.1 registry" | sudo tee -a /etc/hosts

Verify all three routes resolve the same address:

getent hosts registry # host resolver
docker pull registry:5000/<an-image-you-loaded> # host daemon

No insecure-registries entry is needed while the publish stays on loopback: Docker treats 127.0.0.0/8 as insecure-by-default, so plain HTTP is accepted on that route, and the dinds get --insecure-registry from the defaulted setting.

Loading images in is still yours to do — nothing can reach Docker Hub. Save them on a connected machine and push them in:

# connected host
docker pull redis:7-alpine && docker save redis:7-alpine -o redis.tar
# airgapped host, after carrying the file across
docker load -i redis.tar
docker tag redis:7-alpine registry:5000/redis:7-alpine
docker push registry:5000/redis:7-alpine

The registry has no authentication and is bound to loopback by default. Widen REGISTRY_HOST_BIND only if other hosts must pull from it, and add the matching insecure-registries entry to /etc/docker/daemon.json yourself if you do.

Pointing at your own registry​

Two deployment-level keys matter for sessions and for the daemons jobs use. Both are optional and both leave everything unchanged when unset:

# Nested Docker daemons (agent sandbox and IDE session) — for `docker build`
# and Testcontainers inside a job or session.
AGENT_DIND_REGISTRY_MIRROR=https://nexus.corp.example.com
AGENT_DIND_INSECURE_REGISTRY=nexus.corp.example.com:8082

# Internal VS Code extension gallery. Base URL only.
CODE_SERVER_EXTENSIONS_GALLERY_URL=https://vsx.corp.example.com

A registry mirror only rewrites Docker Hub-style references — it does not authenticate, and it does nothing for an image already named nexus.corp/repo/app:1.2.

Separately, the session image still needs network access the first time it is built. It is a different failure at a different moment: the image build fails before a session ever opens, whereas everything above is about what a session resolves once it is running.

Limitations​

  • npm, Maven/Gradle, and NuGet only. pip, Go, and Cargo are not covered.
  • Docker deployments only. A Kubernetes deployment fails the job with a named error rather than silently resolving from the public registries. A BDD test run on Kubernetes fails the same way, for the same reason.
  • Local (non-containerised) test execution is not covered. With AGENT_EXECUTION_MODE unset the client's BDD script runs directly on the host, where there is nowhere to mount a config file. npm could be redirected by an environment variable but NuGet cannot — no environment variable declares a package source — so rather than fix one and silently miss the other, this path carries no registry configuration at all and resolves from the host's own toolchain configuration. Deployments use AGENT_EXECUTION_MODE=docker; this affects local development only.
  • Container images are configured separately — see "Container Images" below. This section covers package managers only.
  • Credentials are readable by the agent. The mount is how the credential is delivered to the toolchain, not a boundary the agent is kept outside of. Provision a dedicated read-only repository account for Repave rather than reusing a developer's.

Container Images​

Separate from package registries: a BDD run starts a throwaway database in a container, and that image has to come from somewhere too. Three settings cover it, all in .env.

1. Trust your registry's certificate. An internal registry serving HTTPS from your own certificate authority is refused by Docker on the TLS handshake, before the image name is even considered. Point at a directory laid out the way Docker expects — one subdirectory per registry host:

/opt/legacy-modernization/registry-certs/
nexus.corp.example.com/ca.crt
AGENT_DIND_REGISTRY_CA_DIR=/opt/legacy-modernization/registry-certs

This is needed for everything below. If your registry serves plain HTTP instead, skip it and use AGENT_DIND_INSECURE_REGISTRY above.

2. Redirect the images that do not name a registry. Most do not — postgres, mysql, mariadb, mongo, and Testcontainers' own Ryuk helper are all bare names that implicitly mean Docker Hub. One setting redirects all of them:

TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX=nexus.corp.example.com/dockerhub/

Include the trailing slash. Nothing in the project changes, so this works for projects scaffolded before you had a registry.

3. Redirect the images that do. The prefix cannot touch an image whose name already names its registry — SQL Server's default is mcr.microsoft.com/mssql/server. Set those individually:

BDD_TESTCONTAINERS_SQLSERVER_IMAGE=nexus.corp.example.com/mssql/server:2022-latest

There is one of these per database — BDD_TESTCONTAINERS_{POSTGRES,MYSQL,MARIADB,MONGODB,SQLSERVER}_IMAGE — but with the prefix set you only need the SQL Server one.

4. If your registry requires a login, point at a Docker config file you already have:

docker login nexus.corp.example.com
mkdir -p /opt/legacy-modernization/registry-auth
cp ~/.docker/config.json /opt/legacy-modernization/registry-auth/config.json
AGENT_REGISTRY_AUTH_FILE=/opt/legacy-modernization/registry-auth/config.json

Repave mounts that file read-only into jobs and sessions and never parses it, so tokens and several registries work the same way. It must use plain auths entries — a credential helper (credsStore/credHelpers) shells out to a binary that is not in the container. docker login writes plain auths by default on Linux.

This does not apply to AGENT_DIND_REGISTRY_MIRROR: a mirror fetch is made by the Docker daemon itself, and Docker offers no way to authenticate it. With an authenticated registry, use the image-name settings in steps 2 and 3 rather than the mirror.

UAT stacks​

A project's UAT stack is a docker-compose.uat.yml that Repave's UAT config agent generates, so its redis/postgres/mongo services are named by Repave, not by you. Point them at your registry with:

UAT_IMAGE_REGISTRY_PREFIX=nexus.corp.example.com/dockerhub/

The same setting also sources the prototype preview's database . A preview runs the project's own compose file on the platform's Docker daemon — not on the nested daemon the AGENT_DIND_* settings configure — so this prefix, or a registry-mirrors/insecure-registries entry in the host's /etc/docker/daemon.json, is what makes that pull work. A preview that cannot pull its database image now says exactly that, and names the daemon it asked.

If you enabled the bundled registry profile, the preview database now pulls from it too. Before, this one path went to the daemon's default registry while your UAT stack — running the same compose file — went to registry:5000. They now agree. If you enabled the profile for agent images only and never pushed the database image into it, seed it there or set UAT_IMAGE_REGISTRY_PREFIX explicitly to the registry that does hold it.

This is a separate setting from TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX, and neither implies the other. They are read by different things at different moments: the Testcontainers prefix is applied by the Testcontainers client library inside a BDD run, while this one is interpolated by docker compose when the UAT stack starts. Set both if you use both.

Note also that no AGENT_DIND_* value affects a UAT stack. Those are dockerd flags for the nested daemons Repave starts for jobs and IDE sessions; a UAT stack is brought up by the app container against the host daemon. If you would rather solve it at the daemon, the equivalent is registry-mirrors in the host's own /etc/docker/daemon.json — which is complementary to this setting, not an alternative to it.

Two consequences worth knowing before you debug one:

  • A green UAT config generation is not proof the stack is portable. The generating agent's own docker compose runs reach its mirrored sandbox daemon, so a bare Docker Hub name resolves for the agent whatever the host can do. The first failure can therefore appear when a user presses Start, long after generation reported success — or later still, once the host daemon's cache is evicted and it has to actually fetch the image.
  • Existing projects keep their old image names until UAT config is regenerated. The generated compose file lives in your repository, and Repave does not rewrite it behind you. Setting the variable and regenerating UAT config is what moves a project onto your registry.

An image that already names its registry — SQL Server's mcr.microsoft.com/mssql/server default — is left alone by the prefix, exactly as with the Testcontainers one. Redirect those in the project's compose file.

What this does not cover​

  • Images named in your own files — the FROM lines in the Dockerfiles you wrote. Those are in your repository; point them at your registry the same way you would for any other build. The Dockerfiles under docker/uat/ that Repave generates build from whatever base image they name, so an airgapped host needs those bases reachable too.

Hostnames Your DNS Cannot Resolve​

If an endpoint a job needs is reachable only by IP — a Google token endpoint behind an internal route, say — do not fix it by editing the host's /etc/hosts. Docker builds a fresh /etc/hosts for every container and never copies the host's, so that entry works from the VM shell and from nowhere else. The symptom is exactly that split: curl https://oauth2.googleapis.com succeeds on the VM and every agent job fails to authenticate.

Prefer a local resolver serving those names, with "dns" set in /etc/docker/daemon.json. It covers the Compose services, the containers the app spawns per job, and containers nested inside a job's own Docker daemon, and needs no configuration in .env.

Where you cannot run or repoint a resolver, set both of these — see Pinning A Hostname For Containers for the full rules:

# Every container the app spawns (agent runners, test/preview/playground
# containers, web IDE sessions). Docker's --add-host format, comma-separated.
DOCKER_EXTRA_HOSTS=oauth2.googleapis.com:10.20.30.40,sts.googleapis.com:10.20.30.42

# The same entries for the long-running Compose services (app, cli-proxy-api,
# claude-mem-worker). One per slot, three slots, no duplicates.
EXTRA_HOST_1=oauth2.googleapis.com:10.20.30.40
EXTRA_HOST_2=sts.googleapis.com:10.20.30.42

Containers started inside a job's or session's nested Docker daemon are not covered by either setting.

Upgrade In An Airgapped Environment​

For each new release:

  1. Copy the new legacy-modernization-<version>-airgap-core.tgz and checksum to the host. Take -airgap-session-bases.tgz too if you use the web IDE — the session bases are version-matched to the release, so an upgrade that skips them leaves the IDE unable to open sessions on the new tags.
  2. Verify and unpack both.
  3. If this deployment runs a toolchain overlay, load its overlay archives for the new version as well. They are built per deployment and are never in the release bundle.
  4. From the unpacked core bundle, run:
cd legacy-modernization-<version>-airgap-core
./scripts/airgap-upgrade.sh --install-root /opt/app-rewrite

That one command loads every image archive in the bundle, checks that each toolchain overlay the deployment declares is present for this version, and runs this release's installer with --airgap. The installer refreshes the deployment assets, re-pins the image tags (following any <KEY>_OVERLAY declaration in .env), backs up Postgres and the bind-mounted data directories, runs the migrations, recreates the containers and verifies the result. When it finishes, the script prints the image pins it left behind and the running containers.

The version is the bundle's own — the script reads it from metadata/. Passing one (./scripts/airgap-upgrade.sh 0.1.121 --install-root …) checks the bundle against it rather than overriding it, which is how running the wrong bundle gets caught before anything is loaded.

If the session bases for the new release were not loaded, the upgrade leaves SESSION_BASE_*_IMAGE on the version that is loaded and says so, rather than pinning an image the web IDE cannot build from.

Doing it by hand instead: docker load -i each archive under images/, then run the installer directly, exactly as the Load Images and install sections show. The script exists because that sequence differs between releases only by a version string.

Troubleshooting​

If Compose tries to pull CLAUDE_MEM_SERVER_IMAGE, SESSION_BASE_*_IMAGE, or CODE_SERVER_DIND_IMAGE from the internet — the images this release bundles — check:

  • The image tag in .env matches a locally loaded image.
  • APP_PULL_POLICY is not always.
  • Those env vars point to the tags you actually loaded from the archive.

If Compose instead pulls POSTGRES_IMAGE, VALKEY_IMAGE, DOCKER_HOST_IMAGE, CLI_PROXY_IMAGE, or NGINX_IMAGE, that is expected — as of these five are not bundled and you are assumed to have preloaded them yourself. See Third-party images: not in the bundle. If the pull fails, that means the image was not preloaded and this host has no route to its upstream registry — docker load it from a connected host instead.

If "Open in VS Code" fails on a first open, the session image build is the thing reaching for the network. Check that the session-base-* archives were loaded and that .env names those exact tags — the build itself resolves no devcontainer features, so a base image is the only registry dependency left. The installer's --airgap preflight checks these images up front, so a run that passed it has already ruled this out.

If a code viewer never renders — a Loading... box that stays forever — or an API documentation page comes up blank, look in the browser's console and network tab, not at the server. Earlier releases had the browser fetch the Monaco editor from cdn.jsdelivr.net and Swagger UI from unpkg.com, so those surfaces failed on any airgapped or proxied network while every server-side check still passed. From this release on, both ship inside the app image and are served from the deployment's own origin under /vendor/, needing no egress at all. A deployment still showing this is running an older image — the fix is the upgrade, not a firewall rule.

If sibling agent containers cannot start, verify the runner images exist:

docker image inspect "$AGENT_RUNNER_IMAGE"

If a BDD run fails starting its database container — a pull error rather than a test failure — work through "Container Images" above in order. The most common cause is the certificate: without AGENT_DIND_REGISTRY_CA_DIR the pull fails on the TLS handshake and the message names the certificate, not the image. The second most common is SQL Server, which the image prefix cannot redirect and which needs BDD_TESTCONTAINERS_SQLSERVER_IMAGE set explicitly. If the error says unauthorized rather than naming a certificate or a host, the registry wants a login — set AGENT_REGISTRY_AUTH_FILE. If it says unauthorized only for images coming through AGENT_DIND_REGISTRY_MIRROR, that is expected: a mirror cannot be authenticated, so use the image-name settings instead.

If an agent job fails at npm install, mvn, or dotnet restore with a name that will not resolve, the project has no package registry configured — or has one the job never received. Check, in this order:

  1. Settings → Integrations → Package Registries for that project, then Test connection. A green result means Repave can reach the registry; a red one names the specific problem.

  2. That the job started after the settings were saved. Configuration is read at dispatch, so a job already running keeps what it started with.

  3. The job's own container, if you still have it:

    docker inspect <agent-container> --format '{{range .Mounts}}{{.Destination}}{{"\n"}}{{end}}' \
    | grep registry-config

    No /etc/repave/registry-config means the project had nothing configured at dispatch time.

If project creation fails with React frontend initialization failed with code 1 (or Next.js initialization failed with code 1), that is the scaffold, not an agent job — see "Project scaffolding" above. It reads the same npm registry setting, so configure it and re-trigger scaffolding from project settings. The app container's own log carries the underlying npm error:

docker compose logs app | grep 'Modernized Init'

ENOTFOUND registry.npmjs.org means the registry was not configured when the scaffold ran. A 404 naming your own repository means the registry was reached but does not carry create-vite (or create-next-app) — seed it, including the latest dist-tag.

If Test connection reports success but jobs still resolve from the public registries, check whether this deployment runs agents on Kubernetes (shouldUseK8s). Registry configuration is Docker-only today; the Kubernetes path refuses the job with a named error rather than running it wrong.

If the failure is a certificate error rather than a name resolution error, the repository is presenting a certificate from an internal CA. Paste the issuing chain into Custom CA certificate — a registry URL alone does not establish trust.

If a job fails reaching Repave itself (host.docker.internal) after you set a proxy, add the host to NO_PROXY. The three platform hosts are excluded automatically, so this only arises with a non-default REPAVE_API_BASE_URL.