Air-gapped sites cannot pip install / npm ci / docker pull, so a build-at-site compose (build: .) fails and reports 'service api is not running'. Add a build-once-ship-image path: - scripts/build-offline-bundle.ps1: on a connected box, build shopdb-flask + pull mysql:8.0, docker save both into one gzipped tarball with a sha256. - docker-compose.airgap.yml: runs pre-loaded images (image:, never build:), drops the ./plugins bind mount (which would mask the image's baked-in plugins with an empty host dir and load zero plugins at an image-only site), and adds a one-shot migrate service (db upgrade + plugin upgrade-all + seed) that api waits on via service_completed_successfully, so 'up -d' brings a working site. - docs/DEPLOY-AIRGAP.md: full runbook (build, transfer+verify, load+run, admin, verify, upgrade, troubleshooting), incl the Zscaler in-build cert caveat. - .env.example: IMAGE_TAG for the air-gap compose to pin the loaded image tag.
5.9 KiB
Air-gapped Docker deploy
For a site with no internet. The site cannot pip install, npm ci, or
docker pull, so nothing is built or pulled there. You build a fully
self-contained image on a connected box, ship it as one tarball, and
docker load + run it at the site.
This is the counterpart to docker-compose.yml (the connected/build template).
The air-gapped stack uses docker-compose.airgap.yml, which differs in three
ways that matter:
image:notbuild: .- the site runs a loaded image; it never builds.- No
./pluginsbind mount - the image already carries every plugin baked in. Binding a host./plugins(which does not exist at an image-only site) would mask the baked plugins with an empty dir and load zero plugins. - A one-shot
migrateservice runsdb upgrade+plugin upgrade-all+ seed beforeapistarts, soup -dalone brings up a working site.
1. Build the bundle (connected box)
On a box with clean access to Docker Hub + PyPI + npm, from the repo root:
pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0
This builds shopdb-flask:0.7.0 (frontend + all Python deps baked in), pulls
mysql:8.0, and writes:
shopdb-stack-0.7.0.tar.gz- both images in one archiveshopdb-stack-0.7.0.tar.gz.sha256- checksum to verify after transfer
The image is self-contained: no pip/npm/registry access is needed at the site.
Building behind Zscaler
If the build box is itself behind GE Zscaler, the in-build pip/npm may fail
with CERTIFICATE_VERIFY_FAILED - the build container has its own cert store
and does not trust GE's re-signing root (host PIP_CERT / NODE_EXTRA_CA_CERTS
do NOT carry into a docker build). Easiest: build from a box with clean
internet (home, cloud, or CI). Otherwise the corp root CA must be trusted
inside the build (a Dockerfile change to accept the CA - not wired today;
see docs/DEVELOPMENT-SETUP.md section 0b for the all-roots PEM bundle).
2. Transfer + verify
Carry shopdb-stack-0.7.0.tar.gz (+ the .sha256), plus
docker-compose.airgap.yml and .env.example, to the site on approved media.
Verify the archive survived the trip:
# PowerShell
(Get-FileHash shopdb-stack-0.7.0.tar.gz -Algorithm SHA256).Hash.ToLower()
# compare against the .sha256 file
# Linux site
sha256sum -c shopdb-stack-0.7.0.tar.gz.sha256
3. Load + run (air-gapped site)
# 1) Load both images into the local Docker.
docker load -i shopdb-stack-0.7.0.tar.gz
docker image ls | grep -E 'shopdb-flask|mysql' # confirm both present
# 2) Configure the site.
cp .env.example .env
# Edit .env - REQUIRED:
# IMAGE_TAG=0.7.0 # MUST match the loaded image tag
# MYSQL_ROOT_PASSWORD=... # strong, unique
# MYSQL_PASSWORD=... # strong, unique (the app's db user)
# SECRET_KEY=... # 32+ random bytes
# JWT_SECRET_KEY=... # 32+ random bytes, different from SECRET_KEY
# CORS_ORIGINS=https://shopdb.site.example # the site's browser origin(s)
# Optional: API_PORT (default 5001), LOG_LEVEL, ZABBIX_URL/ZABBIX_TOKEN.
# 3) Bring it up. The migrate one-shot runs the schema + seed, then api starts.
docker compose -f docker-compose.airgap.yml up -d
# 4) Watch the one-shot finish (it exits 0 when the schema + seed are done).
docker compose -f docker-compose.airgap.yml logs -f migrate
IMAGE_TAG in .env must equal the loaded tag (0.7.0 here); otherwise compose
looks for an image that was never loaded and api/migrate will not start.
4. Create the first admin
Seeding creates permissions, settings, and reference data, but not a login. Make one admin (choose the password; it is not automatable):
docker compose -f docker-compose.airgap.yml exec api \
flask seed admin --username <username> --email <email> --password <password>
# Omit --password to have a strong one generated and printed once.
5. Verify
docker compose -f docker-compose.airgap.yml ps # db + api "running", migrate "exited (0)"
docker compose -f docker-compose.airgap.yml logs api # gunicorn started, no tracebacks
curl -sf http://localhost:${API_PORT:-5001}/api/dashboard/health # or browse the site origin
Then log in at the site origin with the admin created in step 4.
Upgrading to a new version
Build a new bundle on the connected box (-Version 0.8.0), transfer, then at the
site:
docker load -i shopdb-stack-0.8.0.tar.gz
# set IMAGE_TAG=0.8.0 in .env
docker compose -f docker-compose.airgap.yml up -d
The migrate one-shot re-runs db upgrade + plugin upgrade-all + seed (all
idempotent) against the existing data before the new api starts. Back up first
(see docs/BACKUP-RESTORE.md); the db_data volume persists across upgrades.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
service api is not running / manifest ... not found |
The image was not loaded, or IMAGE_TAG in .env does not match a loaded image. docker image ls, fix IMAGE_TAG. Also: never use docker-compose.yml here - its build: . needs internet. |
| Site loads but no plugins / empty nav | You used the wrong compose file. docker-compose.yml bind-mounts ./plugins (absent here) over the baked plugins. Use docker-compose.airgap.yml. |
api never starts, migrate shows an error |
Read logs migrate. A DB-connection error means db is not healthy yet (logs db) or MYSQL_PASSWORD in .env differs from what the db volume was first initialised with. A fresh site with a stale db_data volume needs the volume removed (docker compose ... down -v - DESTROYS data). |
SECRET_KEY must be set (and similar) on up |
A required .env var is empty. Fill every REQUIRED key in step 3. |
Build fails on the connected box at pip/npm |
Zscaler cert - see "Building behind Zscaler" above. |