# Air-gapped Docker deploy > **Topology: Docker, at a site with no internet at all.** The connected > equivalent is [DEPLOY](DEPLOY.md). > **Where this sits:** **install** -> operate -> upgrade. > Later versions: [Upgrading to a new version](#upgrading-to-a-new-version) on > this page, not [UPGRADE](UPGRADE.md), 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: ```powershell pwsh scripts/build-offline-bundle.ps1 -Version ``` This builds `shopdb-flask:` (frontend + all Python deps baked in), pulls `mysql:8.4`, and writes: - `shopdb-stack-.tar.gz` - both images in one archive - `shopdb-stack-.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](DEVELOPMENT-SETUP.md) section 0b for the all-roots PEM bundle. --- ## 2. Transfer + verify Carry `shopdb-stack-.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 # PowerShell (Get-FileHash shopdb-stack-.tar.gz -Algorithm SHA256).Hash.ToLower() # compare against the .sha256 file ``` ```bash # Linux site sha256sum -c shopdb-stack-.tar.gz.sha256 ``` --- ## 3. Load + run (air-gapped site) ```bash # 1) Load both images into the local Docker. docker load -i shopdb-stack-.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= # 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): ```bash docker compose -f docker-compose.airgap.yml exec api \ flask seed admin --username --email --password # Omit --password to have a strong one generated and printed once. ``` --- ## 5. Verify ```bash 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 `), transfer, then at the site: ```bash docker load -i shopdb-stack-.tar.gz # set IMAGE_TAG= 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](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` | Proxy certificate - see "Building behind an inspecting proxy" above. | ## See also - [DEPLOY](DEPLOY.md) - the connected stack, the environment variables, and the reverse-proxy and client-address reference - [BACKUP-RESTORE](BACKUP-RESTORE.md) - back up before every image swap - [CONFIG](CONFIG.md) - what each `.env` value means