Files
shopdb-flask/docs/DEPLOY-AIRGAP.md
cproudlock 6534590fca
Some checks failed
CI / backend (push) Successful in 1m51s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 9s
docker: air-gapped deploy kit (image bundle + offline compose + runbook)
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.
2026-07-23 14:15:34 -04:00

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: not build: . - the site runs a loaded image; it never builds.
  • No ./plugins bind 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 migrate service runs db upgrade + plugin upgrade-all + seed before api starts, so up -d alone 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 archive
  • shopdb-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.