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 one airgapped bundle:
legacy-modernization-<version>-airgap-core.tgz
legacy-modernization-<version>-airgap-core.tgz.sha256
It carries everything the running stack needs, Repave IDE included.
Releases before this one also published a separate
-airgap-session-bases.tgz bundle holding three per-stack Repave IDE images.
That bundle no longer exists: every agent job and every Repave IDE session now
runs the same image, the agent runner, which is already in the core bundle.
The old undifferentiated legacy-modernization-<version>-airgap.tgz (before
0.1.84) no longer exists either. The core bundle's name was kept distinct so an
operator following an older runbook cannot mistake one for the other.
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.
agent-runner is the one runtime image: it runs every agent job, every
Repave IDE session, every target-stack preview and every implementation
runtime. It carries Node 22, JDK 21 with Maven and Gradle, .NET 10, Python 3,
code-server with the Claude Code, Codex and Repave extensions, the Claude Code,
Codex and repave CLIs, a Docker client with Compose and buildx, and a Chrome
browser. Because agents and developers run the same image, code an agent built
and verified behaves the same when you open it in Repave IDE.
dind is the private Docker daemon each Repave IDE session runs beside it.
Third-party images: not in the bundle
Five images this release depends on are not archived in the 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.
What Repave IDE needs offline
Nothing beyond the core bundle. Nothing is built when a session opens: a
session runs the agent runner image directly, alongside its own Docker daemon
from the dind image. There is no per-project image choice and no toolchain
setting — every project, whatever its stack, opens on the same image.
All of it used to be downloaded on the client, on each project's first "Open in
Repave IDE", from github.com, open-vsx.org, the Playwright CDN, npm and apt —
five hosts an airgapped install cannot reach. It is baked in at publish time
instead. The toolchains are baked in for the same reason, so the generated
devcontainer config requests no remote devcontainer features either.
If the image is not loaded, Repave IDE says so and names both the missing tag
and the .env key that pins it (AGENT_RUNNER_IMAGE) — there is no build to
wait for and nothing to retry. In practice agent jobs cannot run without it
either, so a deployment where agents work can open sessions.
The agent runner archive is about 2.9 GB compressed. A deployment that uses Repave IDE downloads less than it did when the IDE needed a separate image; one that never opens Repave IDE downloads somewhat more, because the IDE layer now travels with the agent runner.
When the client's codebase needs an older toolchain
The image carries one version of each toolchain — Node 22, JDK 21, .NET 10. A
provided codebase that targets an older one (Angular 14, .NET 6) will not build
in it, and no .env setting changes that. The answer is a per-client overlay
image: a thin derivation of the published agent runner that adds the legacy
Node and/or .NET SDK beside the shipped ones, built on a connected host and
loaded here like any other archive. One overlay serves agent jobs and Repave IDE
sessions alike. Repave builds it for your deployment and supplies it with the
release.
It is declared once in .env, by repository and without a tag:
AGENT_RUNNER_IMAGE_OVERLAY=repave-overlay-agent-runner-<name>
An overlay must be rebuilt and loaded here for every new version. The pin itself follows the upgrade on its own, from that declaration; an upgrade whose overlay image is missing stops and names the tag rather than reverting the deployment to the stock image .
A deployment upgraded from a release that had a separate Repave IDE overlay
(SESSION_BASE_*_IMAGE_OVERLAY=repave-overlay-session-<name>) and no agent
runner overlay is moved to repave-overlay-agent-runner-<name> automatically
during the upgrade, and the old key is removed. Load that agent runner overlay
archive for the new version before upgrading; the upgrade stops and names it if
it is missing.
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:
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
Run the rest of this guide from the extracted core directory.
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
There is nothing else to load for Repave IDE: its sessions run the agent runner image loaded above.
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)")"
metadata/images.json lists every image in the release, not only the ones
archived in the bundle — metadata/release-manifest.json's includedImages
names the archives the 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 the 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
# Repave IDE sessions run AGENT_RUNNER_IMAGE above; there is no separate
# session image to set. SESSION_BASE_*_IMAGE keys from an earlier release are
# no longer read, and an upgrade removes them.
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
.envgives youREPAVE_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. Repave IDE brings up its own
per-session containers on demand from the preloaded agent runner 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.
- Open the project, then Settings → Integrations → Package Registries.
- 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, orUsername and password.
- Registry URL — the full URL, e.g.
- If your repository presents a certificate from an internal CA, paste the PEM chain into Custom CA certificate. One certificate covers every toolchain.
- If reaching it requires the corporate proxy, set HTTP proxy. Without credentials — authenticated proxies are not supported.
- 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.
| Configured | The 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 certificate | ca-bundle.pem, exported as NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and GIT_SSL_CAINFO |
| HTTP proxy | HTTP_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 generatedNuGet.Configdoes not setallowInsecureConnections. - 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 targetingnet6.0restoresMicrosoft.NETCore.App.RefandMicrosoft.AspNetCore.App.Refat 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 withNU1101/NU1102naming 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 remotehttps://api.nuget.org/v3/index.json; a V2 proxy answers/index.jsonwith 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 skipdotnet restoreentirely. The Target Stack preview and a feature's implementation preview use the same folder, so restarting a preview whose packages are already restored does not restore them again. A preview that runs as a different user from the folder's owner starts without it, and its start log says so. 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 . - Maven builds in a project share the releases earlier builds downloaded. Every
container resolves into a Maven repository of its own. About an hour after a container
finishes, the releases it downloaded, checked against the checksums your repository
serves, are copied into a project store under the data directory,
data/maven-tier/<project>/. Builds on Maven 3.9 or later read that store before asking your repository, and older Maven versions work as before. Builds still ask your repository for anything not yet in the store, and for plugin-prefix and version-range metadata, so they still need it to be reachable. A failed lookup, a SNAPSHOT or a module a build installed is never shared. - Gradle builds read a store built from your main branch. Within an hour of a change to
the main checkout's Gradle build files, the platform resolves that build's dependencies
once into a project store under
data/gradle-tier/<project>/, and every container reads it before asking your repository. Only the main checkout is used: a dependency a feature branch adds comes from your repository until the branch is merged. A build on a Gradle version other than the main checkout's wrapper does not use the store and downloads as before.
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.
Repave 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.
The bundled registry (recommended)
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:
| Daemon | Serves | Reaches the registry by |
|---|---|---|
| Host daemon | UAT stacks, prototype databases | published port, via an /etc/hosts entry |
| Per-job agent dind | agent job workloads | Compose network alias |
| Per-session IDE dind | Repave IDE workloads | Compose 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.
The session image itself needs no network access: it is loaded from the bundle and nothing is built when a session opens.
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_MODEunset 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 useAGENT_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 composeruns 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
FROMlines 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 underdocker/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, Repave 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:
- Copy the new
legacy-modernization-<version>-airgap-core.tgzand checksum to the host. It is the only bundle; Repave IDE needs nothing else. - Verify and unpack it.
- If this deployment runs a toolchain overlay, load its overlay archive for the new version as well. It is built per deployment and is never in the release bundle. A deployment that had a separate Repave IDE overlay loads the agent runner overlay instead (see When the client's codebase needs an older toolchain).
- 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.
An .env from a release with per-stack Repave IDE images still holds
SESSION_BASE_*_IMAGE (and perhaps SESSION_BASE_*_IMAGE_OVERLAY) keys. The
upgrade removes each one and prints a line naming it; nothing reads them any
more, and you do not need to edit .env by hand.
The same upgrade moves agents onto a new operating-system base, so a project
worktree whose node_modules an agent installed before it may hold native
packages built for the old one. The next npm install in that worktree replaces
them — the test runs agents start already do it — so nothing needs deleting; a
build that fails with Cannot find module …-linux-x64-gnu before then is the
sign, and npm install is the fix.
Doing it by hand instead:
docker load -ieach archive underimages/, 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 AGENT_RUNNER_IMAGE, CLAUDE_MEM_SERVER_IMAGE, or
CODE_SERVER_DIND_IMAGE from the internet — the images this release bundles —
check:
- The image tag in
.envmatches a locally loaded image. APP_PULL_POLICYis notalways.- 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 Repave IDE" reports a missing image, check that the agent-runner
archive was loaded and that AGENT_RUNNER_IMAGE (or, with an overlay, the
overlay tag it was pinned to) names that exact tag. A session builds nothing and
resolves no devcontainer features, so that image and dind are its only image
dependencies. The installer's --airgap preflight checks both 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:
-
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.
-
That the job started after the settings were saved. Configuration is read at dispatch, so a job already running keeps what it started with.
-
The job's own container, if you still have it:
docker inspect <agent-container> --format '{{range .Mounts}}{{.Destination}}{{"\n"}}{{end}}' \| grep registry-configNo
/etc/repave/registry-configmeans 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.