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
.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. 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.
- 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. 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.
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 | web 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.
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_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, 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:
- Copy the new
legacy-modernization-<version>-airgap-core.tgzand checksum to the host. Take-airgap-session-bases.tgztoo 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. - Verify and unpack both.
- 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.
- 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 -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 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
.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 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:
-
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.