PSAPLink — Production Setup
All servers run via Docker Compose. No systemd units or manual package installs.
Deploy Repositories
The monorepo is the single source of truth. Three read-only deploy repos are automatically kept in sync by CI — external parties clone these, never the monorepo.
| Deploy repo | Synced from | Who clones it | Workflow |
|---|---|---|---|
psaplink-psap-deploy | psap/ | PSAP IT staff | sync-psap-deploy.yml |
psaplink-field-deploy | cloud/ → field/ after hub-split | Self-hosted company IT | sync-field-deploy.yml |
psaplink-hub-deploy | hub/ (dormant until hub-split) | Future Hub operators | sync-hub-deploy.yml |
One-time setup
For each deploy repo:
- Create the repo on GitHub (empty, no README, private)
- Create a Personal Access Token (fine-grained,
Contents: Read and Writeon that specific repo) - Add the token as a secret in the monorepo settings:
| Secret name | Deploy repo |
|---|---|
PSAP_DEPLOY_TOKEN | psaplink-psap-deploy |
FIELD_DEPLOY_TOKEN | psaplink-field-deploy |
HUB_DEPLOY_TOKEN | psaplink-hub-deploy (create the secret now, repo later) |
- Trigger the first sync manually from the Actions tab → select the workflow → Run workflow
After the first run, syncs happen automatically on every push to main that touches the relevant directory.
After hub-split
Update sync-field-deploy.yml in two places (marked with comments in the file):
- Change the
pathstrigger fromcloud/**tofield/** - Change
--prefix=cloudto--prefix=fieldin the subtree split step
Servers
| Server | Compose file | Domain |
|---|---|---|
| PSAPLink Cloud | cloud/docker-compose.yml + cloud/docker-compose.prod.yml | psaplink.com |
| PSAPLink PSAP | psap/docker-compose.yml | PSAP internal network |
| Self-hosted Field (future) | field/docker-compose.yml (after hub-split) | company's own domain |
For local development of the full stack together, use the root docker-compose.yml which runs both Cloud and PSAP on a shared internal network.
Cloud — psaplink.com
Service layout
cloud/
├── Dockerfile Multi-stage: Node builds React → PHP-FPM serves
├── docker-compose.yml Base (also used for dev)
├── docker-compose.prod.yml Production overlay — adds Caddy TLS, removes bind mounts
├── docker-entrypoint.sh Runs migrations + transport seed on start; dev-seed only in dev
├── nginx/
│ ├── default.conf nginx config for monorepo compose (cloud-php:9000)
│ └── standalone.conf nginx config for cloud-only deploy (php:9000)
└── docker/
└── caddy/
└── Caddyfile.prod Caddy TLS config for production
Services in production
| Service | Role |
|---|---|
caddy | External-facing — handles HTTPS (auto Let's Encrypt) + Mercure SSE proxy |
nginx | Internal — serves static assets, proxies PHP requests to php:9000 |
php | PHP 8.2 FPM — Symfony app |
worker | messenger:consume — notification delivery queue |
scheduler | scheduler:run — CFS pollers + ACK tasks |
db | PostgreSQL 15 + PostGIS |
redis | Queue backend for Messenger |
mercure | Mercure SSE hub (real-time dashboards) |
DNS records
Create these A records pointing to your VPS IP before deploying:
| Record | Purpose |
|---|---|
psaplink.com | Company dashboard |
www.psaplink.com | Caddy redirects to apex |
admin.psaplink.com | Superadmin console |
hub.psaplink.com | Reserve now — activate when hub-split is done |
Environment variables
Copy .env.example to .env.local and set:
APP_ENV=prod
APP_SECRET=<32_random_chars>
# DB
DB_PASSWORD=<strong_password>
# JWT
JWT_PASSPHRASE=<strong_passphrase>
# Encryption — DO NOT LOSE THIS KEY
# If lost, all encrypted transport credentials become unrecoverable.
# Generate: php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
LIBSODIUM_ENCRYPTION_KEY=<64_hex_chars>
# Email
MAILER_DSN=smtp://user:pass@smtp.example.com:587
# Inbound webhook validation
TWILIO_TOKEN=<twilio_auth_token>
WEBHOOK_SECRET=<strong_secret>
# Mercure
MERCURE_JWT_SECRET=<strong_mercure_secret>
The docker-compose.prod.yml override sets MERCURE_PUBLIC_URL=https://psaplink.com/.well-known/mercure and tightens CORS automatically — no additional config needed.
First deploy
# On the VPS — clone the repo
git clone <repo-url> /srv/psaplink && cd /srv/psaplink/cloud
# Configure
cp .env.example .env.local
# Edit .env.local — set the values above
# Generate JWT keys (once)
docker compose run --rm php php bin/console lexik:jwt:generate-keypair
# Build images + run migrations (entrypoint handles migrations automatically)
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
The entrypoint runs doctrine:migrations:migrate and psap:transport:seed-types on every container start. Dev seed is skipped when APP_ENV=prod.
Subsequent deploys
cd /srv/psaplink/cloud
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml build php worker scheduler
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps php worker scheduler
Migrations run automatically via the entrypoint on the next container start.
Smoke test
curl https://psaplink.com/api/v1/health
# {"status":"ok","version":"x.x.x","db":"ok"}
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs worker --tail=20
Backups
# Database dump
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec db \
pg_dump -U psaplink_user psaplink_cloud | gzip > backup-$(date +%Y%m%d).sql.gz
Also back up .env.local (contains LIBSODIUM_ENCRYPTION_KEY) to a secure location separate from the server.
PSAPLink PSAP — each PSAP's internal network
Service layout
psap/
├── Dockerfile PHP 8.2 FPM (no nginx needed — built-in PHP server)
├── docker-compose.yml Standalone PSAP deploy
└── docker-entrypoint.sh Runs migrations + optional bootstrap on start
Services
| Service | Role |
|---|---|
app | PHP built-in server on port 8000 — local PSAP API |
worker | messenger:consume — outbound event queue |
scheduler | scheduler:run — CFS pollers + ACK poll task |
db | PostgreSQL 15 (local, PSAP-managed) |
Environment variables
APP_ENV=prod
APP_SECRET=<32_random_chars>
DB_PASSWORD=<strong_password>
JWT_PASSPHRASE=<strong_passphrase>
CLOUD_BASE_URL=https://psaplink.com
CLOUD_API_KEY=<raw_api_key_from_cloud>
CLOUD_PSAP_ID=<psap_agency_uuid_from_cloud>
ACK_MODE=poll
First install
cd /srv/psaplink-psap # or wherever you deploy on the PSAP network
git clone <repo-url> . --sparse -- psap/
# or just copy the psap/ directory
cp .env.example .env.local
# Edit .env.local
# Generate JWT keys (once)
docker compose run --rm app php bin/console lexik:jwt:generate-keypair
# Bootstrap PSAP config from Cloud (pulls PSAP metadata + CFS configurations)
AUTO_BOOTSTRAP=true docker compose up -d
# Or bootstrap manually after start:
docker compose up -d
docker compose exec app php bin/console psaplink:psap:bootstrap \
--cloud-url=https://psaplink.com \
--api-key=<your-raw-api-key>
CFS file access
The scheduler needs to read your CAD system's XML export files. Mount the CAD export folder into the scheduler and app services by adding to docker-compose.yml:
# Under scheduler and app services:
volumes:
- /path/to/cad/exports:/var/www/html/var/cfs/inbox:ro
- cfs_processed:/var/www/html/var/cfs/processed
Then set cfs_configurations.inbox_path = /var/www/html/var/cfs/inbox in the database (populated during bootstrap or via the Cloud superadmin UI).
Subsequent updates
git pull
docker compose build app worker scheduler
docker compose up -d --no-deps app worker scheduler
Firewall note
The PSAP agent is outbound-only by default (ACK_MODE=poll). Port 8000 is exposed on the internal PSAP network for the local dispatcher API only — do not expose it to the internet.
If using ACK_MODE=callback, expose port 443 to incoming connections from psaplink.com's IP only:
CALLBACK_ACK_IP_ALLOWLIST=<psaplink.com_IP>/32
Dev (both apps together)
Use the root docker-compose.yml from psaplink/:
cd /srv/psaplink
# Start everything (Cloud + PSAP on shared network)
docker compose up -d
# Start with Vite dev server + mailpit
docker compose --profile dev up -d
# Bootstrap PSAP from Cloud automatically
AUTO_BOOTSTRAP=true docker compose up -d
# Stop and wipe volumes
docker compose down -v
Service names are prefixed: cloud-php, cloud-nginx, cloud-db, psap-php, psap-db, etc.