Deployment Guide
Development (Docker Compose)
Prerequisites
- Docker and Docker Compose
- Node.js 18+ (for mobile app development)
Setup
git clone https://github.com/hightower-systems/sentry-wms.git
cd sentry-wms
cp .env.example .env
# Generate the five required secrets and paste them into .env:
# JWT_SECRET -- openssl rand -hex 32
# SENTRY_ENCRYPTION_KEY -- python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# SENTRY_TOKEN_PEPPER -- python -c "import secrets; print(secrets.token_hex(32))"
# SENTRY_PUBSUB_HMAC_KEY -- python -c "import secrets; print(secrets.token_hex(32))"
# REDIS_PASSWORD -- python -c "import secrets; print(secrets.token_hex(32))"
# docker compose refuses to interpolate when any of these are missing,
# and the api / dispatcher containers refuse to boot on weak / placeholder
# values (V-201). Use the generators above for production deploys.
docker compose up -d
This starts five containers:
- sentry-db -- PostgreSQL 16 on port 5432 (bound to localhost only)
- sentry-api -- Flask API on port 5000
- sentry-redis -- Redis 7 (broker for Celery, no host port)
- sentry-celery -- Celery worker for connector sync tasks
- sentry-admin -- React admin panel served by nginx on port 8080
For local development with Vite dev-server and hot reload:
The overlay replaces the nginx admin with the Vite dev-server on port 3000
and mounts ./api and ./admin into their containers for live reload.
Test database (v1.7.0+)
The pytest suite TRUNCATEs ~40 tables at session start. To keep that
wipe from destroying the application database, the conftest hard-fails
unless TEST_DATABASE_URL is set to a separate database AND distinct
from DATABASE_URL.
The default docker-compose stack creates an empty sentry_test
database during the postgres image's first-init (see
db/create-test-db.sql). Run the suite with:
docker exec \
-e TEST_DATABASE_URL=postgresql://sentry:sentry@db:5432/sentry_test \
sentry-api pytest tests/
If you migrated from a pre-v1.7.0 stack, the existing postgres volume
will not have the test database; run docker compose down -v && docker
compose up -d to re-init from scratch, or create the test database
manually:
docker exec sentry-db psql -U sentry -d postgres \
-c "CREATE DATABASE sentry_test;"
docker exec -i sentry-db psql -U sentry -d sentry_test \
< db/schema.sql
Admin Login
Fresh installs seed the admin user as admin / admin with must_change_password=true. Auth middleware blocks every endpoint except change-password and logout until you set a new password from the first-login screen.
To skip the forced change (CI, deterministic dev environments), set ADMIN_PASSWORD in your .env. The seed uses that value directly and prints it on startup:
Demo Data
The default seed includes 1 warehouse, 6 zones, 16 bins, 20 items, 5 POs, and 20 SOs for testing. To start with a clean system:
Running Tests
647 backend tests using transaction-rollback isolation (54 skipped inside the
api container for infrastructure-config assertions; run on the host with
python -m pytest tests/ to get full coverage).
Upgrading
When upgrading Sentry WMS, you MUST rebuild Docker images after pulling new code. Python or JavaScript dependencies may have changed, and cached images will not include new dependencies.
Correct upgrade procedure:
If you see ModuleNotFoundError or similar errors after upgrading, you skipped the build step.
Starting in v1.4.2, sentry-api detects this condition at startup: if the Docker image's baked-in version does not match the source code version, the container logs a clear upgrade-procedure message and exits with code 2 rather than crashing a worker with a dependency error.
Production
Required Environment Variables
All of the following are required. docker compose refuses to start if any are missing:
# Application auth
JWT_SECRET=$(openssl rand -hex 32)
# Connector credential vault (Fernet, base64, 32 bytes)
SENTRY_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
# X-WMS-Token pepper (v1.5.0 #128). SHA256(pepper || plaintext) is the
# stored hash; rotating the pepper invalidates every issued token.
# Boot guard rejects short / whitespace / placeholder values (V-201).
SENTRY_TOKEN_PEPPER=$(python -c "import secrets; print(secrets.token_hex(32))")
# Cross-worker pubsub HMAC (v1.6.1 #227 / #238). Signs the
# webhook_subscription_events Redis channel envelope; required when the
# dispatcher is enabled (default). Generate with:
SENTRY_PUBSUB_HMAC_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
# Inbound staging-row forensic retention (v1.7.0 R6). Default 90 days.
# The retention beat task NULLs source_payload past this many days
# rather than DELETing rows so cross_system_mappings + canonical FKs
# stay intact. Hard floor 7 days enforced at boot (V-201 shape):
# typo'd or zero values refuse to start the api.
SENTRY_INBOUND_SOURCE_PAYLOAD_RETENTION_DAYS=90
# Inbound per-request body cap (v1.7.0 #273). Default 256 KB; valid
# range [16, 4096]. Boot guard refuses out-of-range or unparseable
# values rather than silently clamping (pre-#273 a typo like 42096
# silently degraded to 4096 with no signal).
SENTRY_INBOUND_MAX_BODY_KB=256
# Inbound mapping-document directory (v1.7.0 #279). Default
# /db/mappings (absolute, matches the docker-compose ./db:/db volume
# mount). The mapping_loader reads <source_system>.yaml files from
# this directory at boot. Override only when running outside docker.
SENTRY_INBOUND_MAPPINGS_DIR=/db/mappings
# Redis broker password (Celery)
REDIS_PASSWORD=$(python -c "import secrets; print(secrets.token_hex(32))")
# Database
DATABASE_URL=postgresql://user:pass@db:5432/sentry
POSTGRES_USER=your-db-user
POSTGRES_PASSWORD=your-db-password
# Allowed browser origins for the admin panel / mobile
CORS_ORIGINS=https://your-admin-domain.com
SENTRY_ENCRYPTION_KEY in particular is load-bearing: rotating it
requires decrypting every row of connector_credentials with the old
key and re-encrypting with the new one. Treat it like a master key.
The app does not auto-generate a replacement -- missing values raise
RuntimeError at startup.
Production Docker Compose
Use docker-compose.prod.yml which has no source volume mounts:
Key differences from the dev compose:
- No
./api:/app,./db:/db, or./admin:/appvolume mounts SKIP_SEED=trueby default- Every secret required via env var (no defaults); hard-fail on missing
FLASK_ENV=productionhardcoded- Redis requires
--requirepass $REDIS_PASSWORD; Celery broker URL uses the authenticated form - Admin container is a multi-stage nginx build serving the compiled Vite bundle; Vite dev-server is unavailable in production
Required migration
Before running v1.3.0+ against an existing v1.2 database, apply migration
db/migrations/016_audit_log_tamper_resistance.sql. It adds the
prev_hash / row_hash columns on audit_log, installs the hash-chain
trigger and the BEFORE UPDATE / BEFORE DELETE guards, and exposes
verify_audit_log_chain() for periodic integrity checks. v1.4.0 adds
migrations 017_sync_state_running_since.sql and
018_sync_state_run_id.sql for the stale-running sync recovery (V-012)
and sync_state race fix (V-102).
Infrastructure Notes
- PostgreSQL port is bound to
127.0.0.1:5432only (not exposed to the network) - API and admin ports default to
127.0.0.1(see LAN development access below) - API runs Gunicorn with 4 workers (not the Flask dev server)
- Container runs as non-root user
appuser debug=Falseis hardcoded inapp.py
Content-Security-Policy
A strict CSP header is set by the API and mirrored by nginx in the admin
container. default-src 'self', script-src 'self' with per-build SRI
hashes, style-src 'self' 'unsafe-inline', img-src 'self' data:,
font-src 'self', connect-src 'self'. If you override CORS_ORIGINS
you do not need to edit the CSP -- connect-src 'self' is same-origin
only and browser requests to the API go through the admin origin.
Rate limiting
Flask-Limiter is enabled by default, backed by the same Redis broker as
Celery. Global default is 300/minute per client; sensitive endpoints
(auth, sync-reset, connector test-connection) have tighter per-route
quotas. The limiter reads from the REDIS_PASSWORD-authenticated
broker URL; set RATELIMIT_STORAGE_URI explicitly if you want a
separate Redis instance.
Self-hosted fonts
Instrument Sans and JetBrains Mono ship inside admin/public/fonts/
under the SIL Open Font License. No third-party font requests are made
at runtime -- relevant if you have strict egress controls on the
warehouse network.
LAN development access
The API and admin ports are parametrized via API_BIND_HOST and
ADMIN_BIND_HOST. Both default to 127.0.0.1, which is the
correct posture for production deployments behind a reverse proxy
and for any cloud-hosted install (V-040). For LAN development
where a phone or handheld scanner on the same network needs to
reach the API directly, override either or both in your local
.env:
.env is gitignored, so the override stays on the dev machine
and does not ship to production.
Reverse Proxy (HTTPS)
The API serves HTTP only. For HTTPS, put a reverse proxy in front:
TRUST_PROXY (required behind a reverse proxy)
When Sentry runs behind a reverse proxy, set TRUST_PROXY=true in the API's environment:
The Flask app wraps app.wsgi_app in Werkzeug's ProxyFix when this flag is set, so request.scheme, request.host, and request.is_secure reflect the headers the proxy forwards (X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-For) instead of the internal http://127.0.0.1:5000 hop. Without this, cookies issued at login are scoped to the internal hostname, the browser never resubmits them to the public hostname, and every CSRF-protected POST / PUT / PATCH / DELETE returns 403 CSRF token missing or invalid (#107).
Security warning. Only enable
TRUST_PROXYwhen Sentry actually runs behind a reverse proxy on a network the proxy controls. If the app is reachable directly (no proxy in front, or a proxy that forwards from the public internet without stripping inboundX-Forwarded-*headers), any client can forge its own scheme, hostname, and client IP by sending those headers.TRUST_PROXYis opt-in for exactly this reason. The default-off deployment is safe against header forgery.
Where to set it, and how to apply the change
TRUST_PROXY goes in the .env file at the repo root (next to docker-compose.yml), NOT api/.env. docker-compose.yml reads .env from the Compose project directory; api/.env is only picked up by a direct flask run from inside the api/ folder and is not consulted by the containerised deployment.
After editing .env, the api container must be recreated, not just restarted, for the new value to take effect. Compose picks up .env changes when it creates a container, not when it starts one. A common footgun:
docker compose up -d-- recreates the container when the config has changed, which is what picks up the new.envvalue.docker compose restart api-- keeps the existing container and just bounces the process inside it, which does NOT re-read.env.
Verification
Two checks confirm TRUST_PROXY actually reached the Flask app after docker compose up -d:
# 1. Compose forwarded the env var into the container.
docker compose exec api env | grep TRUST_PROXY
# Expected: TRUST_PROXY=true
# 2. Flask read it at startup and wired ProxyFix.
docker compose logs api | grep ProxyFix
# Expected: "ProxyFix active: trusting X-Forwarded-* headers (TRUST_PROXY=true)"
# One line per gunicorn worker (4 lines on the default worker count).
Or, from an authenticated admin session, hit the system-info endpoint:
# Log in first to get a bearer token (TOKEN=...), then:
curl -s -H "Authorization: Bearer $TOKEN" \
https://sentry.yourcompany.com/api/admin/system-info
# {"proxy_fix_active":true}
A response with "proxy_fix_active": false behind a reverse proxy means TRUST_PROXY did not reach the container. Check docker-compose.yml (v1.4.4 shipped without the Compose-side wiring; v1.4.5 added it; #136) and confirm you ran up -d, not restart, after changing .env.
v1.5.1 note (V-215). The unauthenticated
/api/healthendpoint no longer reportsproxy_fix_active. Exposing proxy deployment state to anonymous callers helped an attacker shape their approach (e.g. deciding whetherX-Forwarded-Forspoofing would stick). The field moved to the admin-only/api/admin/system-info; the anonymous/api/healthnow returns{status, service}only.
nginx
Minimum config. Each header has a specific job; the comments explain why each one is required:
server {
listen 443 ssl;
server_name sentry.yourcompany.com;
ssl_certificate /etc/ssl/certs/your-cert.pem;
ssl_certificate_key /etc/ssl/private/your-key.pem;
location / {
proxy_pass http://127.0.0.1:5000;
# Public hostname the browser used. ProxyFix rewrites
# request.host from this so cookies scope to the public
# hostname instead of 127.0.0.1.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
# Scheme the browser used. ProxyFix rewrites request.scheme
# and request.is_secure from this so the Secure cookie flag
# and HSTS header emit correctly.
proxy_set_header X-Forwarded-Proto $scheme;
# Real client IP for audit logs and rate limiting.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Caddy
Caddy's reverse_proxy directive sets all the required X-Forwarded-* headers automatically:
Traefik (v2+)
Traefik sets the forwarded headers by default for any service reached through a router. A minimum dynamic-config snippet:
http:
routers:
sentry:
rule: "Host(`sentry.yourcompany.com`)"
entryPoints: [websecure]
tls: {}
service: sentry-api
services:
sentry-api:
loadBalancer:
servers:
- url: "http://127.0.0.1:5000"
AWS ALB and other TLS-terminating load balancers
ALB, GCP HTTPS Load Balancer, Azure Application Gateway, Cloudflare Tunnels, Fly.io, Render, and most other managed edges all send X-Forwarded-Proto and X-Forwarded-For. TRUST_PROXY=true works the same way for all of them.
Multi-hop deployments (CDN in front of a proxy)
The default ProxyFix config trusts one proxy hop. When Sentry sits behind multiple TLS-terminating proxies (e.g. Cloudflare CDN -> nginx -> Sentry, or ALB -> nginx -> Sentry), increase the hop count in api/app.py:
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=2, # one entry each from CDN and nginx
x_proto=2,
x_host=2,
x_prefix=0,
)
The hop count must match the number of trusted proxies in the chain exactly. Over-counting accepts forged headers from the innermost proxy's client (a request originator can prepend fake X-Forwarded-* entries that a too-permissive ProxyFix will trust); under-counting scopes cookies to the wrong hop. Upstream Werkzeug documents this at https://werkzeug.palletsprojects.com/en/latest/middleware/proxy_fix/.
Inbound (v1.7.0)
The v1.7.0 release adds a Pipe B inbound API. External systems POST canonical-shaped resource updates to /api/v1/inbound/{sales_orders,items,customers,vendors,purchase_orders} instead of (or alongside) running a connector against Sentry. Per-source mapping documents translate source-system payloads into Sentry's canonical model. This section covers the operator setup; see api/services/mapping_loader.py, the inbound OpenAPI, and the v1.7.0 release notes for the API contract.
Configuring an inbound source_system
For every source system you intend to ingest from, four pieces have to exist before the api will accept inbound POSTs:
- Allowlist row in
inbound_source_systems_allowlist. Boot fails loud when an allowlisted source has no mapping doc OR a doc has no allowlist row.
INSERT INTO inbound_source_systems_allowlist
(source_system, kind)
VALUES ('your-source-system', 'connector');
-- 'connector' | 'internal_tool' | 'manual_import'
-
Mapping document YAML at
db/mappings/<source_system>.yaml. Filename stem must equal thesource_systemfield inside the doc. Start from the annotated template atdb/mappings/example-template.yaml.template(the.templatesuffix excludes the template itself from the boot loader). The template covers all five resources with every required canonical column markedrequired: trueplus comments naming the schema constraint, every supportedtype:(string / integer / decimal / boolean / uuid / iso_timestamp / enum), andcross_system_lookupexamples onsales_orders.customer_idandpurchase_orders.vendor_id. -
WMS token issued via the admin panel's API tokens page with:
source_system= your-source-systeminbound_resourcescontaining the resources the token can write to (subset of: sales_orders, items, customers, vendors, purchase_orders)- The
mapping_overridecapability checkbox is reserved for v1.7.1; the v1.7.0 handler rejects requests withmapping_overridesregardless of the flag.
-
docker compose restart apisoboot_load()picks up the new mapping doc. There is no hot-reload. Each restart writes a freshMAPPING_DOCUMENT_LOADaudit_log row carrying the file's sha256 so investigators can correlate which mapping doc was active when a given inbound POST was processed.
Boot validators
Three boot guards refuse to start the api on misconfiguration:
- Canonical-column shape (#267): every mapping doc field's
canonical:name must correspond to a real column on the canonical table. A typo or stale field name fails boot loud with the file path, resource block, and offending field. No more 500-on-first-POST surprises. - Eval-shape derived expressions (#272): static AST walker rejects derived expressions whose AST contains forbidden names (
__import__,eval,exec,open,compile, etc.), attribute walks not rooted atsource, or call targets outside the function whitelist (int,float,str,len,abs,min,max,round). A malicious expression in awhen_present-gated branch that smoke testing never triggers cannot sit dormant. SENTRY_INBOUND_MAX_BODY_KBrange (#273): refuses to boot on parse failure or values outside[16, 4096]. Pre-fix the helper silently clamped to the boundaries and silently fell back to 256 on parse failure; a typo (e.g.42096vs4096) silently degraded with no signal at deploy time.
Load testing
The k6 script at tools/loadtest/inbound_v1_7.js drives all five inbound endpoints with realistic payloads under concurrent load. See docs/loadtest.md for the operator runbook (k6 install, ramp profiles, expected baselines, threshold-trip triage). Operator-run, not CI-default.
Mobile App
Sideloading the APK
Download the APK from the GitHub Releases page. sentry-wms-v1.5.1.apk is the current recommended baseline; v1.6.0, v1.6.1, and v1.7.0 ship no mobile code changes (the dispatcher daemon, admin Webhooks page, the v1.6.1 webhook security patch, and the v1.7.0 inbound API are server-side surfaces) and the v1.5.1 APK carries the dependency-tree security overrides from #158 and #61. Operators still on v1.4.1 or v1.4.3 should install v1.5.1 to pick up those fixes.
Install via ADB:
Or transfer the APK to the device and open it from the file manager.
First Launch
On first launch, the app prompts for the API server URL:
- Enter your server's IP and port (e.g.,
http://10.0.0.150:5000) - The app runs a health check before accepting the URL
- Log in with your admin credentials
- Select a warehouse (shown as a blocking modal after login)
The server URL can be changed later from Settings in the user dropdown menu.
Broadcast Intent Scanning (Chainway C6000)
For hardware scanners that use Android broadcast intents instead of keyboard wedge:
- Open Settings from the user dropdown
- Switch scan mode from KEYBOARD to INTENT
- Configure the intent action and extra key for your device
Default values for Chainway C6000:
- Intent action:
com.android.scanner.BARCODE_READ - Extra key:
barcode_string
Expo Go (Development)
For development testing without building an APK:
Scan the QR code with Expo Go on your device. Set the API URL to your dev machine's IP.