Skip to main content

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 repoSynced fromWho clones itWorkflow
psaplink-psap-deploypsap/PSAP IT staffsync-psap-deploy.yml
psaplink-field-deploycloud/field/ after hub-splitSelf-hosted company ITsync-field-deploy.yml
psaplink-hub-deployhub/ (dormant until hub-split)Future Hub operatorssync-hub-deploy.yml

One-time setup

For each deploy repo:

  1. Create the repo on GitHub (empty, no README, private)
  2. Create a Personal Access Token (fine-grained, Contents: Read and Write on that specific repo)
  3. Add the token as a secret in the monorepo settings:
Secret nameDeploy repo
PSAP_DEPLOY_TOKENpsaplink-psap-deploy
FIELD_DEPLOY_TOKENpsaplink-field-deploy
HUB_DEPLOY_TOKENpsaplink-hub-deploy (create the secret now, repo later)
  1. 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 paths trigger from cloud/** to field/**
  • Change --prefix=cloud to --prefix=field in the subtree split step

Servers

ServerCompose fileDomain
PSAPLink Cloudcloud/docker-compose.yml + cloud/docker-compose.prod.ymlpsaplink.com
PSAPLink PSAPpsap/docker-compose.ymlPSAP 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

ServiceRole
caddyExternal-facing — handles HTTPS (auto Let's Encrypt) + Mercure SSE proxy
nginxInternal — serves static assets, proxies PHP requests to php:9000
phpPHP 8.2 FPM — Symfony app
workermessenger:consume — notification delivery queue
schedulerscheduler:run — CFS pollers + ACK tasks
dbPostgreSQL 15 + PostGIS
redisQueue backend for Messenger
mercureMercure SSE hub (real-time dashboards)

DNS records

Create these A records pointing to your VPS IP before deploying:

RecordPurpose
psaplink.comCompany dashboard
www.psaplink.comCaddy redirects to apex
admin.psaplink.comSuperadmin console
hub.psaplink.comReserve 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.


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

ServiceRole
appPHP built-in server on port 8000 — local PSAP API
workermessenger:consume — outbound event queue
schedulerscheduler:run — CFS pollers + ACK poll task
dbPostgreSQL 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.