Files
shopdb-flask/docs/DEPLOY-AIRGAP.md
cproudlock c6c806667e
Some checks failed
CI / backend (push) Failing after 7m18s
CI / naming (push) Failing after 7m14s
CI / frontend (push) Failing after 7m13s
CI / migrations-mysql (push) Failing after 7m10s
Run the database version the rest of the product already recommends
INSTALL-WINDOWS-IIS.md has said MySQL 8.4 LTS is standard for new installs since
8.0 reached end of life in April 2026, while both compose files and the offline
bundler still pinned 8.0. A site reading the Windows runbook and a site reading
the Docker one were being told to run different servers, and the migration page
written this week sent people onto the dead one.

Verified against a real server rather than by editing a tag: 8.4.11, core chain
plus five plugin chains applied clean, 66 tables at a single utf8mb4_unicode_ci
collation, six alembic version tables. The image's PyMySQL authenticates against
8.4's caching_sha2_password, which is what requirements.in already pins
cryptography for.

Existing servers need one thing done FIRST: 8.4 removes mysql_native_password,
so an account created on 5.6 or 5.7 must be moved to caching_sha2_password
before the upgrade or it cannot authenticate afterwards. In-place also has no
downgrade path, and 5.7 cannot reach 8.4 in one hop. For databases this size a
dump into a fresh 8.4 server is the better trade: same outage, and the old
server stays as the rollback.

Air-gapped sites need a fresh offline bundle, because the tarball carries the
MySQL image alongside the app image.

Also here, found by having it bite during that verification: the db healthcheck
pinged over the unix socket, and the entrypoint's init pass answers on the
socket while running the server with --skip-networking. The probe therefore
reported healthy DURING init, which is what `depends_on: service_healthy` gates
api and migrate on. A ping passed at 8 seconds and the next query failed because
the server was mid-restart. Probing 127.0.0.1 keeps it red until the real server
is listening.
2026-08-19 19:57:40 -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.4, 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.