Files
shopdb-flask/docs/DEPLOY-AIRGAP.md
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

7.3 KiB

Air-gapped Docker deploy

Topology: Docker, at a site with no internet at all. The connected equivalent is DEPLOY. Where this sits: install -> operate -> upgrade. Later versions: Upgrading to a new version on this page, not UPGRADE, which assumes a checkout to pull.

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.

docker-compose.airgap.yml is the counterpart to docker-compose.yml and differs in four 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.
  • No ./certs mount and no TLS flags - api runs the image's own gunicorn command and serves plain http on 5001. Put TLS in front of it, or in the container the way docker-compose.yml does.

A ready-made package exists too. scripts/build-channel-installer.ps1 wraps this same tarball with docker-compose.airgap.yml, a pre-filled .env and an Install-ShopDB.ps1, for an operator who has no repository at all. Read the script's own header for what it expects on the build box.


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 <version>

This builds shopdb-flask:<version> (frontend + all Python deps baked in), pulls mysql:8.4, and writes:

  • shopdb-stack-<version>.tar.gz - both images in one archive
  • shopdb-stack-<version>.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 an inspecting proxy

If the build box is itself behind an inspecting corporate proxy, the in-build pip/npm fails with CERTIFICATE_VERIFY_FAILED: the build container has its own certificate store, and the host's PIP_CERT / NODE_EXTRA_CA_CERTS do NOT carry into a docker build. Easiest fix is to build from a box with clean internet. Otherwise the corporate root CA must be trusted inside the build, which is a Dockerfile change and is not wired today - see DEVELOPMENT-SETUP section 0b for the all-roots PEM bundle.


2. Transfer + verify

Carry shopdb-stack-<version>.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-<version>.tar.gz -Algorithm SHA256).Hash.ToLower()
# compare against the .sha256 file
# Linux site
sha256sum -c shopdb-stack-<version>.tar.gz.sha256

3. Load + run (air-gapped site)

# 1) Load both images into the local Docker.
docker load -i shopdb-stack-<version>.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=<version>             # 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; otherwise compose looks for an image that was never loaded and api/migrate will not start. docker-compose.airgap.yml falls back to 0.7.0 when IMAGE_TAG is unset, which is almost certainly not the version you just built - so set it, rather than relying on the default.


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 <newversion>), transfer, then at the site:

docker load -i shopdb-stack-<newversion>.tar.gz
# set IMAGE_TAG=<newversion> 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 (BACKUP-RESTORE); 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 Proxy certificate - see "Building behind an inspecting proxy" above.

See also

  • DEPLOY - the connected stack, the environment variables, and the reverse-proxy and client-address reference
  • BACKUP-RESTORE - back up before every image swap
  • CONFIG - what each .env value means