Skip to content

Installing without internet access

A host with no route to the internet runs the same stack as any other. Two things differ: the images arrive some other way, and the controller is told not to reach out on its own.

Each release carries CPM’s own three images (web, caddy and agent) as files. The other images the stack runs (the database, the Docker socket proxy, ClickHouse and CrowdSec) belong to other projects, and you fetch them through whatever channel your site approves. They are pinned by digest, so whichever way they come in, they can be checked against the release.

Every release has, per architecture (amd64 and arm64):

File What it holds
-airgap-web.tar.gz, -airgap-caddy.tar.gz, -airgap-agent.tar.gz docker save of each of CPM’s images
-airgap-deploy.tar.gz The release’s deployment files, plus airgap-load.sh
-airgap-manifest.txt The checksum of every file above, and the third-party images by digest
-airgap-manifest.txt.sigstore.json The manifest’s signature

A file of 2 GiB or more ships in parts (.part-aa, .part-ab, …). Keep the parts together; the load script reads them in order and needs no joining.

Check the signature on a connected machine before carrying the files over. It is a keyless Sigstore signature made by the release workflow, so it names that workflow at the release’s tag:

Terminal window
cosign verify-blob \
--bundle caddy-proxy-manager-v<version>-amd64-airgap-manifest.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/CaddyProxyManager/caddy-proxy-manager/\.github/workflows/docker-build-trusted\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
caddy-proxy-manager-v<version>-amd64-airgap-manifest.txt

The checksums come from the manifest itself, so a verified manifest vouches for every other file. The load script checks them again on the target host.

ServiceImageRuns
docker-socket-proxywollomatic/socket-proxy:1.13.1always
postgrespostgres:18-alpinealways
clickhouseclickhouse/clickhouse-server:26.9.12.8-alpinewith the clickhouse profile
crowdseccrowdsecurity/crowdsec:v1.8.1with the crowdsec profile

By digest, as this release's compose file and air-gap manifest name them:

Terminal window
docker pull wollomatic/socket-proxy:1.13.1@sha256:3935b709275e4ec35d6ed5a5c4a1f0d01ed31eec5e7234efc3357ecd47689002
docker pull postgres:18-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
docker pull clickhouse/clickhouse-server:26.9.12.8-alpine@sha256:d94d23b5cf0a0a0433a1b5b909f8900bf126783e8c3c7b806add464cc0604794
docker pull crowdsecurity/crowdsec:v1.8.1@sha256:0f2523fa61ef507f15d953045cface490cc880670c62f2755ced17524107f71a

The standard main and next deployment bundles include PostgreSQL. The nodb channels omit its container but still require an external PostgreSQL server; SQLite is only for development, tests, and demos. ClickHouse and CrowdSec are needed only if you turn on analytics or managed CrowdSec; the agent starts them, and it runs them from images already on the host.

Pick one way to bring them in.

All four come from Docker Hub, so a pull-through mirror covers them without touching the compose file. Point the Docker daemon at it in /etc/docker/daemon.json:

{ "registry-mirrors": ["https://mirror.example.internal"] }

Restart the daemon, then pull them through the mirror before you run the load script:

Terminal window
docker compose --profile '*' pull postgres docker-socket-proxy clickhouse crowdsec

The compose file names each one by digest, so what the mirror hands over is checked on the way in.

If the images live in your own registry under other names, name them in a docker-compose.override.yml beside docker-compose.yml, with the same digests:

services:
postgres:
image: registry.example.internal/postgres:18-alpine@sha256:<the digest above>
docker-socket-proxy:
image: registry.example.internal/socket-proxy:1.13.1@sha256:<the digest above>

Keep the digest. The load script and Compose both refuse a reference whose digest differs from the release’s. The agent passes docker-compose.override.yml to every Compose call it makes, so the ClickHouse and CrowdSec containers it starts use your names too.

On a connected machine, pull each image by its reference above and save them to one file:

Terminal window
docker save -o third-party.tar \
postgres:18-alpine@sha256:<digest> \
wollomatic/socket-proxy:1.13.1@sha256:<digest>

Then docker load -i third-party.tar on the target host. Only Docker’s containerd image store keeps the digest an image was pulled by through a save and load, and Compose finds the image by that digest. It is the default on fresh installs of Docker Engine 29; docker info -f '{{ .DriverStatus }}' shows io.containerd.snapshotter.v1 when it is on. With the older store, use a registry instead; the load script names every image it cannot find.

  1. Put every file of the bundle for your architecture in one directory, say ~/cpm-bundle, and unpack the deploy archive into the installation’s own directory:

    Terminal window
    mkdir caddy-proxy-manager
    tar -xzf ~/cpm-bundle/caddy-proxy-manager-v<version>-amd64-airgap-deploy.tar.gz -C caddy-proxy-manager
    cd caddy-proxy-manager

    Keep that directory name, for the reason the install guide gives.

  2. Generate .env as for any install, and turn offline mode on from the start:

    Terminal window
    echo "SESSION_SECRET=$(openssl rand -base64 32)" >> .env
    echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)" >> .env
    echo "OFFLINE_MODE=true" >> .env
    chmod 600 .env
  3. Load the images:

    Terminal window
    ./airgap-load.sh ~/cpm-bundle

    It checks every file against the manifest, refuses a bundle for another architecture, loads CPM’s images, then checks that each third-party image the compose files would run is on this host at the release’s digest. It pulls nothing and starts nothing. When an image is missing, it names it and stops.

  4. Start the stack:

    Terminal window
    docker compose up -d

    Then carry on with first run.

To upgrade, do the same with the next release’s bundle: unpack its deploy archive over the directory (your .env and docker-compose.override.yml are not in it), run the load script, and docker compose up -d.

With no DNS server reachable, Docker answers a name it does not know (caddy-admin before Caddy first runs, an upstream on another network) with a failure the controller and the agent would otherwise wait 5 seconds on. The compose file gives both dns_opt of timeout:2 and attempts:2, so such a lookup gives up after 2 seconds; on a connected host, answers arrive long before that. For a slower DNS server, replace the list in docker-compose.override.yml with !override, since the first timeout listed is the one that counts:

services:
web:
dns_opt: !override ["timeout:5", "attempts:2"]

Offline mode stops every call the controller would make to the internet on its own:

  • the update check;
  • Let’s Debug, when someone asks it about a domain;
  • Gravatar, so avatars fall back to initials;
  • the CRS plugin registry;
  • MaxMind’s GeoIP downloads;
  • browser push notifications;
  • Caddy image builds by the agents.

Turn it on in Settings → Outbound connections, or with OFFLINE_MODE=true in .env as above. That page lists every connection the deployment can open, whether it goes to the internet, to a destination you configured, or stays inside the stack, and what offline mode does to it. Destinations you configured, such as SMTP, LDAP, single sign-on providers, alert webhooks and backup storage, are still reached, since an offline network can host all of them itself.

With downloads off, upload the databases instead. Download the GeoLite2 .mmdb files on a connected machine, then pick each under Settings → Geo-blocking → GeoIP databases → Upload a database. The file is opened first, so a corrupt file or another edition is refused rather than replacing a working one. Agents then fetch it from the controller as they would a download.

Building Caddy downloads its Go modules, so in offline mode every agent behaves as if it had CADDY_BUILD_MODE=external: it never builds, and a change of modules waits for an image you built. Build it on a connected machine as Caddy build describes, bring it in like any other image, and use Load built image. The shipped Caddy image, from the bundle, already carries every module CPM ships with.

The CrowdSec image downloads the collections it is configured with from its hub the first time it starts, and talks to CrowdSec’s online API if signal sharing is on. Offline, keep signal sharing off, and expect the managed container to need its collections from a mirror of the hub, or use an external CrowdSec your site already runs.

Caddy’s default issuers are public ACME certificate authorities, which an offline host cannot reach. Use one of:

  • An internal ACME CA. Set its directory under Settings → General → ACME server and supply its root, as certificates describes.
  • Imported certificates, issued however your site issues them, with expiry tracked. See manual imports.
  • Certificates from files that something else renews on the agent’s host. See certificates from files.