# Deploying Packet Station How Packet Station runs on `docker-1`. Sections not built yet are marked "planned, phase N". The design is in [PLAN.md](PLAN.md) under "Deployment and CI". ## Host facts - Host: `docker-1`, 192.168.101.12. - Ubuntu 24.04 LTS, Docker 29, Compose v5. Clock in UTC. - Nginx Proxy Manager already runs there as container `npm_app_1` on network `npm_default` (172.22.0.0/16). Its compose project is in `/home/spencerm/npm`. - The host also runs Gitea (`gt.pescoinc.biz`, the container registry and the Python index) and `pesco-ncr`. - Proxmox Backup Server backs up the whole VM at 03:00 daily. - Host patching and hardening are IT's (requirement H5). - Inside containers only full names resolve. Docker's resolver has no search domain, so every host setting is an FQDN (for example `vsql.pesco.com\DEV`, `app-03.pesco.com`). ## The two stacks Both stacks run from the same image as separate compose projects. | Stack | Compose project | DNS name | Network alias on npm_default | VISUAL | |---|---|---|---|---| | Test | `packetstation-test` | `packetstation-test.pescoinc.biz` | `packetstation-test-app` | DEV: `vsql.pesco.com\DEV` / `PESCOINCDEV` | | Production | `packetstation` | `packetstation.pescoinc.biz` | `packetstation-app` | `vsql` / `PESCOINC`, set explicitly (planned, phase 10) | - The app publishes no port. It joins `npm_default` as an external network with an explicit alias per stack (`APP_ALIAS`). Plain service names would collide, since both stacks share that network. - PostgreSQL stays on each stack's own internal `backend` network, never on `npm_default`. - The test stack never points at VISUAL production. The production stack sets `VISUAL_SERVER` and `VISUAL_DATABASE` explicitly. Spencer starting it with those values is the one-time approval for its scheduled polling. - There is no M-Files test vault. Both stacks read the production vault, read-only, as `mfileskitRO`. - The internal A records for both names point at 192.168.101.12 in the AD-integrated `pescoinc.biz` zone. ## Services - `postgres`: PostgreSQL 16, named volume, `backend` network only. Its init creates the database roles from secrets. - `migrate`: one-shot, runs `packetstation migrate` as the migrate role. - `app`: `packetstation app` (uvicorn). `backend` network plus egress plus `npm_default` through the proxy overlay. Assets volume, CA bundle bind, secrets, healthcheck. - `worker`: `packetstation worker`. Same image. Runs the VISUAL feed, the drawing lookup and housekeeping (see [FEED.md](FEED.md)). `backend` network plus egress (VISUAL, M-Files), no `npm_default`, no published port. Assets volume (fetched drawings land there), CA bundle bind, backup folder read-only (used from phase 9). Its healthcheck reads the heartbeat file. - `clamav` (optional, profile `clamav`, phase 4): `clamav/clamav:1.4`, pinned by tag (with a comment in the compose file), not by digest. clamd on port 3310 for the app and the worker, reached by the network alias `packetstation-clamav` (as `postgres` has its own alias). `backend` network plus egress (freshclam downloads the signatures), no `npm_default`, no published port. Volume `clamav-db` at `/var/lib/clamav`, `mem_limit: 2g`, healthcheck `clamdcheck.sh`. Started only with `--profile clamav`. See "Virus scan (ClamAV)". - All services restart unless stopped, log to stdout (json-file), and have healthchecks. ## Secrets Docker secrets, one file each, in the stack's `secrets/` folder ("Env files" says where each stack keeps it). Production: owner `root:10001` (10001 is the image's non-root user), mode `0440`. Test: owner `spencerm`, mode `0444`, reachable only through `/home/spencerm` and `packetstation/test`, both mode `0750`, so no other host user can read them. | Secret | Used by | |---|---| | `postgres_superuser_password` | PostgreSQL bootstrap | | `postgres_migrate_password` | migrate role (`ps_migrate`) | | `postgres_app_password` | app role (`ps_app`) | | `visualkit_password` | `VISUAL_PASSWORD_FILE` for `visualkit_ro` | | `mfileskit_password` | `mfileskitRO`; the entrypoint exports it as `MFILES_PASSWORD` | | `ldap_bind_password` | `LDAP_PASSWORD_FILE` for `svc-packetstation` | | `session_key` | session signing | | `seal_key` | HMAC filing seals and controlled-copy stamps | Never put a secret in the repo, the env file, a log or chat. Settings read secrets through `*_PASSWORD_FILE` paths. The worker gets `postgres_app_password` (it logs in as the app role, with application name `ps-worker`), `visualkit_password`, `mfileskit_password`, `ldap_bind_password` and `seal_key`. The worker never logs a password: job errors are redacted before they are stored, and the redaction also strips the VISUAL and M-Files passwords themselves. The PESCO CA chain is mounted into the containers once and referenced by `LDAP_CA_BUNDLE`, `MFILES_CA_BUNDLE` and `SMTP_CA_BUNDLE`. ## Env files One folder per stack. The test stack: `/home/spencerm/packetstation/test/`, like the other compose stacks on docker-1 (decided 2026-10-18, Gitea issue 38). It holds `stack.env`, `secrets/`, `ca/pesco-ca.pem` and `backups/`, and its `stack.env` sets `ENV_FILE`, `SECRETS_DIR`, `CA_BUNDLE_FILE` and `BACKUP_DIR` to those paths. The compose files run from the checkout in `/home/spencerm/packetstation/repo/deploy`. The production stack: `/etc/packetstation/prod/` until issue 38 says otherwise. Each folder holds `stack.env` (settings only, no secrets) and `secrets/`. The env file sets `PROJECT_NAME`, `SECRETS_DIR` and `APP_ALIAS`, which the compose files require. `.env.example` in the repo lists every setting. Key settings: - `VISUAL_SERVER`, `VISUAL_DATABASE`, `VISUAL_USER=visualkit_ro`, `VISUAL_PASSWORD_FILE`. - `MFILES_URL=https://mfiles.pescoinc.biz/REST`, `MFILES_VAULT_GUID={BB69270D-023E-4EA8-B899-AD20B1349702}`, `MFILES_USERNAME=mfileskitRO`, `MFILES_CA_BUNDLE`. - `LDAP_URL=ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636`, `LDAP_BIND_DN=CN=svc-packetstation,OU=System_Accounts,OU=Admin,DC=PESCO,DC=com`, `LDAP_GROUP_BASE_DN=OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,DC=PESCO,DC=com`, `LDAP_PASSWORD_FILE`, `LDAP_CA_BUNDLE`. - `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `SMTP_FROM=noreply@pescoinc.biz`, `SMTP_CA_BUNDLE`. - `APP_ALIAS` per stack. - Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_HIDDEN_LOCK_SECONDS=60`, `SESSION_PASSWORD_HOURS=12`, `SESSION_PIN_HOURS=10`, `ROLES_REFRESH_MINUTES=15`, `SIGNIN_ACCOUNT_LIMIT=5`, `SIGNIN_ACCOUNT_WINDOW_MINUTES=5`, `SIGNIN_ACCOUNT_COOLDOWN_MINUTES=10`, `SIGNIN_IP_LIMIT=30`, `SIGNIN_IP_WINDOW_MINUTES=5`, `PIN_MAX_FAILURES=5`, `STEPUP_MAX_FAILURES=3`, `DEVICE_COOKIE_DAYS=365`, `PIN_MIN_DIGITS=6`, `PIN_MAX_DIGITS=12`. - Worker sources, all optional: `FEED_SOURCE` (`visualkit`, the default, or `fixture`), `DRAWING_SOURCE` (`mfiles`, the default, or `fixture`) and `FIXTURE_DIR` (default `/fixtures`). A fixture source reads files instead of VISUAL or M-Files (see [FEED.md](FEED.md), "Fixture sources"). `STACK=prod` refuses a fixture source at start. The test and production stacks leave all three unset. Only the CI env file sets them. - Uploads and storage, all optional: `UPLOAD_MAX_BYTES` (default 25 MB, drawings, MTRs and documents), `UPLOAD_MAX_BYTES_BOM` (default 5 MB, hand BOMs), `ASSETS_QUOTA_WARN_PCT` (default 85, 1 to 99) and `ASSETS_QUOTA_BYTES` (default 0, which means the size of the assets volume; any other value is a byte budget for the stored files). See "Storage quota". - Virus scan, all optional: `CLAMAV_HOST` (empty by default, which means no scan; `packetstation-clamav` for the profile service), `CLAMAV_PORT` (default 3310) and `CLAMAV_TIMEOUT_SECONDS` (default 30). The env examples carry `#CLAMAV_HOST=packetstation-clamav` commented out. See "Virus scan (ClamAV)". - `SITE_ID` (default `PESCO`) and `SITE_TZ` (default `America/Denver`): the VISUAL site in every call, and the time zone for shop hours and the schedule. - The session cookie is marked `Secure` when the request arrived over https (as reported by NPM through `X-Forwarded-Proto`). Behind the proxy that is always the case. ## Feed settings live in the database The feed's schedule and rules are not env settings. Intervals, shop hours, the window, the go-live date, departments, the drawing recheck and budget, labor tickets, auto-release and the pause switch are one `settings` row (key `feed`), edited by an Admin on the Admin page, Feed tab. The resource map is the `visual_resource_map` table, edited on the "VISUAL resources" tab. A save notifies the worker, which applies it without a restart. A new stack starts with the defaults in [FEED.md](FEED.md), "Settings". Set the go-live date before the production feed's first run. ## Deploy command Test stack: ```bash docker compose -p packetstation-test --env-file ~/packetstation/test/stack.env \ -f deploy/compose.yaml -f deploy/compose.proxy.yaml up -d ``` Production (planned, phase 10): the same command with `-p packetstation` and the production env file. The first production start is Spencer's approval. Images come from `gt.pescoinc.biz` by tag. CI tags each tested image `:sha-`; a release re-tags it `vX.Y.Z`. Never `latest`. A deploy is a new image with a short downtime window. ## Virus scan (ClamAV, phase 4) ClamAV is optional and off by default. It closes requirement D5. IT decided on 2026-10-18 (Gitea issue 19) to leave it off in both stacks: every workstation that uploads runs Microsoft Defender. The steps below stay for the day that changes. Turn it on in the test stack first, check it, then the production stack. 1. Set `CLAMAV_HOST=packetstation-clamav` in the stack's env file (`~/packetstation/test/stack.env`). Leave `CLAMAV_PORT` and `CLAMAV_TIMEOUT_SECONDS` at their defaults. 2. Start the stack with the profile: ```bash docker compose -p packetstation-test --env-file ~/packetstation/test/stack.env \ -f deploy/compose.yaml -f deploy/compose.proxy.yaml --profile clamav up -d ``` 3. Wait for `clamav` to turn healthy. On the first start the volume `packetstation-test_clamav-db` is empty, so freshclam downloads the full signature set (a few hundred MB) before clamd answers. That takes several minutes. Later starts reuse the volume and only fetch updates. freshclam needs egress to the ClamAV mirrors, which is why the service is on the `egress` network. 4. Check it: Admin, Storage tab, the ClamAV line reads "packetstation-clamav: reachable". `packetstation doctor` also has a `clamav` check (a PING to clamd; SKIP when `CLAMAV_HOST` is empty). Upload a small PDF to a draft and confirm it is stored. Every later `up` for that stack must carry `--profile clamav`. Without it the service is not started. Fail closed. While `CLAMAV_HOST` is set, an upload the scanner cannot check is refused with "The virus scanner is not reachable. Try again in a minute." (503 `scan_unavailable`). That covers clamd still loading signatures, stopped, or out of memory. The worker's drawing fetches are refused the same way and tried again later. A file with a signature hit is refused (422 `infected`), never stored, and audited as `asset.rejected` with the signature name. To turn it off: remove `CLAMAV_HOST` from the env file, recreate `app` and `worker` (`up -d` without the profile), then stop the service with `docker compose ... --profile clamav stop clamav`. The volume can stay for the next time. The CI overlay never runs ClamAV. clamd holds the signatures in memory, about 1.2 GB with the full set and more while it reloads them, so the service has a 2 GB limit. Check docker-1 has the headroom for both stacks before turning it on in production. ## Storage quota (phase 4) Every stored file and the housekeeping job check the assets volume. With `ASSETS_QUOTA_BYTES=0` (the default) the check reads the volume's own used and total bytes; with a byte budget it adds up the stored files' sizes against it. At `ASSETS_QUOTA_WARN_PCT` percent (85 by default) the app opens one `notification_events` alert of kind `assets_quota` and audits `storage.quota_warning`. When usage drops back under, the alert is resolved. A failed check is logged and never fails an upload. The Admin page, Storage tab, shows the usage bar (used of total, percent, the threshold), the open quota alert with an Acknowledge button, the ClamAV line ("Not configured", or the host with reachable or not reachable) and every open alert. Phase 9 sends the alert by email to PS-Admins (decided 2026-10-18, Gitea issue 20, which also kept the 85 percent threshold). Until then, check the Storage tab. ## Python packages and the Gitea index The Gitea PyPI index `https://gt.pescoinc.biz/api/packages/spencerm/pypi/simple/` serves only `visualkit`, `mfileskit` and their dependencies. - Install `visualkit` and `mfileskit` from it with `--index-url`. Never `--extra-index-url`, which would let a public package with the same name win. - Install everything else from public PyPI in a separate pip step. - The image needs Microsoft ODBC Driver 18 (`msodbcsql18`) from Microsoft's Debian 12 repository for VisualKit. ## NPM proxy host One proxy host per stack in Nginx Proxy Manager. To be recorded under `deploy/` as well. - Domain: `packetstation-test.pescoinc.biz` (test) or `packetstation.pescoinc.biz` (production). - Scheme `http`, forward host the stack's alias (`packetstation-test-app` or `packetstation-app`), port `8000`. - SSL: the stack's certificate, Force SSL on, HTTP/2 on. - NPM HSTS off. The app sends HSTS itself. - Advanced config: `client_max_body_size 30m;` plus a location block for the event stream: ```nginx client_max_body_size 30m; location /api/events { proxy_pass $forward_scheme://$server:$port; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } ``` The block uses NPM's `$server` variables so the alias is re-resolved. The app also sends `X-Accel-Buffering: no` on `/api/events`. Open check (plan question 16): confirm "Block common exploits" does not interfere with uploads when the test proxy host is made. The app trusts `X-Forwarded-*` only from `npm_app_1`'s own address, re-resolved every 60 seconds. Other stacks share `npm_default`, and the per-address rate limit depends on the real client address. ## Known issue: the internal CA signs with SHA-1 (found 2026-10-17) PESCO-PSYLOCKE-CA (valid to 2027-09-27) signs with sha1WithRSAEncryption, and so do the certificates it issued to DC-02.PESCO.COM and PDC-1.PESCO.COM on 636 and to APP-03.PESCO.COM on 25 (all valid to April 2027). OpenSSL 3 on Debian 12 refuses SHA-1 signatures at its default security level 2, so LDAPS and SMTP STARTTLS fail from inside the image with "CA signature digest algorithm too weak", while Windows and browsers still accept them. Verification passes at security level 0. mfiles.pescoinc.biz uses a public Let's Encrypt certificate, so MFILES_CA_BUNDLE must stay unset; only LDAP_CA_BUNDLE and SMTP_CA_BUNDLE point at the PESCO chain. Resolved 2026-10-17: the CA was switched to SHA-256 (`certutil -setreg ca\csp\CNGHashAlgorithm SHA256`, restart certsvc; the CA already used the Key Storage Provider) and the Kerberos Authentication, Domain Controller Authentication and Directory Email Replication certificates on DC-02 and PDC-1 and the Machine certificate on APP-03 were renewed with `certreq -enroll -machine -q -cert renew reusekeys`. From docker-1 all three endpoints now verify against the PESCO bundle with sha256WithRSAEncryption. The CA certificate itself is still SHA-1 self-signed, which OpenSSL accepts for a trust anchor. If the problem comes back, the fix was: set the CA to issue with SHA-256 and reissue the domain controller and relay certificates. The CA certificate itself may stay SHA-1, since OpenSSL does not check the trust anchor's own signature. Until then the test stack cannot sign in or verify the relay without a test-only OpenSSL security-level override, which is Spencer's decision to apply, never the production stack's. The doctor should report a SHA-1 chain with this hint (follow-up). The test stack on docker-1 lives under /home/spencerm/packetstation/test (stack.env, secrets/, ca/pesco-ca.pem, backups/). It stays there: that matches the other compose stacks on the host (Gitea issue 38, 2026-10-18). ## Known issue: Docker subnet pool on docker-1 (found 2026-10-17) Docker's default address pool on docker-1 holds fifteen /16 bridge networks (172.17.0.0 to 172.31.0.0). Each compose stack takes one or two (Packet Station takes two: backend and egress), the Gitea runner takes one per job, and the CI e2e stack needs two more. With the test stack up the pool ran out and every e2e job failed within seconds with "all predefined address pools have been fully subnetted". Idle networks of stopped stacks were removed to make room. The lasting fix is a larger pool in /etc/docker/daemon.json, for example `{"default-address-pools":[{"base":"10.200.0.0/16","size":24}]}`, which needs sudo and a Docker restart (a short outage for every stack on the host), or trimming unused compose projects. When the production stack joins, count the networks first: `docker network ls --filter driver=bridge | wc -l`. ## Known issue: vsql serves a weak self-signed certificate (found 2026-10-18) `vsql.pesco.com\DEV` serves TLS (`VISUAL_ENCRYPT=trust` connects), but with `VISUAL_ENCRYPT=yes` OpenSSL in the image refuses it with "EE certificate key too weak": the server certificate's RSA key is under 2048 bits, which is what SQL Server's auto-generated self-signed certificate looks like. IT needs to issue vsql a 2048-bit, SHA-256 certificate from PESCO-PSYLOCKE-CA with `vsql.pesco.com` in the subject alternative names, install it on the DEV and production instances in SQL Server Configuration Manager and restart them. The image then needs the PESCO CA in its OpenSSL trust store for the ODBC driver (the entrypoint can concatenate the system bundle and /etc/ssl/pesco/ca.pem into /tmp and export `SSL_CERT_FILE`), and the stacks switch to `VISUAL_ENCRYPT=yes`. Tracked as Gitea issue 5. ### Installing the vsql certificate `scripts/Request-VsqlServerCertificate.ps1` does the request, the checks and the binding. Spencer runs it on the SQL Server host, in an elevated PowerShell, as an account that may enroll from the WebServer template. Every step there is a real change, so preview first. Copy only the script to `C:\ProgramData\PacketStation\` and work from there. 1. Preflight. Nothing changes. Check the instance names, the service accounts, the INF and the list of steps. ```powershell cd C:\ProgramData\PacketStation powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Instances DEV -WhatIf ``` 2. DEV first. This enrolls, validates, grants the DEV service account read on the key, saves a snapshot and binds DEV. Production is untouched. ```powershell powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Instances DEV ``` If the CA holds the request as pending, the script stops with the request id. Issue it on the CA (certsrv.msc, Pending Requests), then run the `certreq -retrieve` line it printed and continue with `-CerFile -Instances DEV`. If validation fails, nothing is bound. The usual cause is a template that forces a Key Storage Provider. 3. Restart DEV and check the log. `Restart-Service -Name 'MSSQL$DEV' -Force`, then start `SQLAgent$DEV` again if it ran. Or rerun with `-Thumbprint -Instances DEV -RestartInstances`, which leaves the binding alone and does both. The instance's ERRORLOG should read `The certificate [Cn = vsql.pesco.com] was successfully loaded for encryption`. If the instance does not start, roll back (step 6). 4. Check from docker-1. Export the certificate (`Export-Certificate -Cert Cert:\LocalMachine\My\ -FilePath vsql.cer`) and copy it to docker-1. OpenSSL cannot speak TDS, so `openssl s_client` does not work against SQL Server. Verify the file at the image's security level instead: ```bash openssl x509 -inform DER -in vsql.cer -out vsql.pem openssl x509 -in vsql.pem -noout -subject -dates -ext subjectAltName,keyUsage,extendedKeyUsage openssl verify -auth_level 2 -purpose sslserver -CAfile ~/packetstation/test/ca/pesco-ca.pem vsql.pem ``` Then prove the live connection with the doctor and `VISUAL_ENCRYPT=yes`. The image does not yet add the PESCO CA to OpenSSL's trust store for the ODBC driver (the issue 5 follow-up), so this one-off check builds a bundle in `/tmp`. It skips the entrypoint, so the M-Files password line fails. Read the VISUAL line. ```bash docker compose -p packetstation-test --env-file ~/packetstation/test/stack.env \ -f deploy/compose.yaml -f deploy/compose.proxy.yaml run --rm --no-deps \ -e VISUAL_ENCRYPT=yes --entrypoint sh app -c \ 'cat /etc/ssl/certs/ca-certificates.crt /etc/ssl/pesco/ca.pem > /tmp/ca.pem && SSL_CERT_FILE=/tmp/ca.pem exec packetstation doctor' ``` That the ODBC driver honours `SSL_CERT_FILE` is untested. If the VISUAL line fails with an issuer error while `openssl verify` passed, the trust step is the gap, not the server. 5. Production, off-hours. Production (`vsql`, `PESCOINC`) is the default instance, `MSSQLSERVER`. Bind the same certificate. VISUAL users lose their connection during the restart. ```powershell powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Thumbprint -Instances MSSQLSERVER -WhatIf powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Thumbprint -Instances MSSQLSERVER -RestartInstances ``` Check the ERRORLOG line again. The production stack's own doctor run is Spencer's, with its own approval. 6. Rollback. Each deployment saved `deploy-