Deployment

Production panel topology, secrets, and health probes.

Modern DarkRP Control — Deployment

Covers the recommended production topology, first-time setup, secret rotation,

health probes, and PostgreSQL cutover. For GitHub → Netlify preparation, see

DEPLOY_NETLIFY.md.

updates, and rollback. Security rationale lives in SECURITY.md;

day-to-day operation lives in OPERATIONS.md.

---

1. Architecture

                      ┌──────────────────────────┐
   Staff browser ──►  │  Reverse proxy (TLS)     │
      HTTPS           │  nginx / Caddy           │
                      └────────────┬─────────────┘
                                   │ http://127.0.0.1:3000
                      ┌────────────▼─────────────┐
                      │  Next.js dashboard + API │  (node, process-managed)
                      └────────────┬─────────────┘
                                   │ TCP, private network only
                      ┌────────────▼─────────────┐
                      │  PostgreSQL 14+          │
                      └──────────────────────────┘
                                   ▲
                      HTTPS, HMAC  │
                      ┌────────────┴─────────────┐
                      │  GMod srcds + Modern     │
                      │  agent addon (0.7.0)     │
                      └──────────────────────────┘

Notes:

  • The dashboard and API are the same Next.js process; there is no separate backend service.
  • The database must not be exposed to the internet. Bind it to a private interface.
  • The GMod server only needs outbound HTTPS to the dashboard host. It does not need to accept inbound connections from the dashboard.
  • Run one dashboard instance. Rate limiting, the replay store, and the SSE hub are per-process; if you must scale horizontally, add sticky sessions at the proxy and read the residual risks in SECURITY.md.

2. Production database

PostgreSQL 14 or newer is the supported production database. SQLite remains the development default and is explicitly rejected by the production environment check.

Why: concurrent writes from heartbeats, gameplay ingest, and AC ingest exceed what SQLite's single-writer model handles comfortably at 64–128 players, and the operational story (managed backups, PITR, replication, connection pooling) does not exist for a file database.

To target PostgreSQL:

  1. Set the datasource provider in dashboard/prisma/schema.prisma:
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
  1. Set DATABASE_URL="postgresql://user:password@host:5432/moderndarkrp?schema=public".
  2. Generate a fresh initial migration for the PostgreSQL baseline (the committed migrations are SQLite DDL):
npx prisma migrate diff \
  --from-empty \
  --to-schema-datamodel prisma/schema.prisma \
  --script > prisma/migrations/00000000000000_postgres_baseline/migration.sql
npx prisma migrate deploy

The Prisma schema itself is portable: every model uses String/Int/DateTime/Boolean and JSON is stored as text, so no type rewrites are required. Keep the SQLite migration folder for local development or maintain two migration directories — do not run SQLite DDL against PostgreSQL.

Connection pooling: use PgBouncer (transaction mode) or the managed provider's pooler, and set ?pgbouncer=true&connection_limit=10 on the URL if pooling.

3. Environment variables

Copy dashboard/.env.example to dashboard/.env and fill it in. Validate with:

npm run env:check

| Variable | Required | Purpose |

|---|---|---|

| DATABASE_URL | yes | PostgreSQL connection string in production |

| NODE_ENV | yes | Must be production to enable secure cookies, HSTS, signed-agent enforcement |

| APP_URL | yes | Public HTTPS URL of the dashboard |

| ALLOWED_ORIGINS | yes | Comma-separated origins accepted for mutating requests (CSRF) |

| OWNER_USERNAME / OWNER_PASSWORD | seed only | Initial owner account; the default password is rejected in production |

| AGENT_TOLERATION_SECONDS | no (60) | Signed-request timestamp window |

| HEARTBEAT_DEGRADED_SECONDS | no (20) | Presence threshold |

| HEARTBEAT_OFFLINE_SECONDS | no (45) | Presence threshold |

| COMMAND_TIMEOUT_MS | no (120000) | Claimed-command recovery window |

| RATE_LIMIT_LOGIN | no (10/min) | Login attempts per IP and per username |

| RATE_LIMIT_AGENT_HEARTBEAT | no (60/min) | Per server |

| RATE_LIMIT_AGENT_INGEST | no (240/min) | Gameplay/progression per server |

| RATE_LIMIT_AGENT_ACK | no (240/min) | Command acknowledgements per server |

| RATE_LIMIT_SEARCH | no (60/min) | Per user |

| RATE_LIMIT_MUTATION | no (60/min) | Per user, on commands/config/staff/rotation |

| AGENT_EVENT_RATE_LIMIT / AGENT_EVENT_RATE_WINDOW_MS | no | Anti-Cheat ingest limiter |

| ALLOW_UNSIGNED_AGENT | no | Migration escape hatch; rejected by env:check in production |

ALLOW_UNSIGNED_AGENT exists only for operators upgrading from an agent older than 0.7.0. Remove it as soon as the agent is updated.

4. Reverse proxy

The proxy must terminate TLS and forward the original protocol so HSTS and secure cookies behave. Example nginx server block:

server {
    listen 443 ssl http2;
    server_name panel.example.com;

    ssl_certificate     /etc/letsencrypt/live/panel.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Server-Sent Events: no buffering, long timeouts
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
        proxy_set_header Connection "";
    }
}

server {
    listen 80;
    server_name panel.example.com;
    return 301 https://$host$request_uri;
}

proxy_buffering off is required — with buffering on, the SSE stream will appear to hang and the dashboard will show a stale/disconnected realtime badge.

5. Process supervision

systemd unit:

[Unit]
Description=Modern DarkRP Control Dashboard
After=network.target postgresql.service

[Service]
Type=simple
User=moderndarkrp
WorkingDirectory=/opt/moderndarkrp/dashboard
EnvironmentFile=/opt/moderndarkrp/dashboard/.env
ExecStartPre=/usr/bin/npm run env:check
ExecStart=/usr/bin/npm run start
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

ExecStartPre makes a misconfigured environment fail the unit instead of silently starting an insecure instance.

Health probes for your monitoring system:

  • Liveness: GET /api/health → 200 while the process is up.
  • Readiness: GET /api/health/ready → 200 only when the database responds, 503 otherwise.

Neither endpoint requires authentication and neither exposes counts, sizes, versions, or credentials.

6. First production setup

  1. Provision the database. Create the PostgreSQL database and a dedicated user with ownership of the schema.
  2. Configure the environment. Copy .env.example.env, set DATABASE_URL, APP_URL, ALLOWED_ORIGINS, NODE_ENV=production, and a strong OWNER_PASSWORD. Run npm run env:check.
  3. Apply migrations. npx prisma migrate deploy.
  4. Create the initial owner. npm run db:seed (uses OWNER_USERNAME/OWNER_PASSWORD). Log in and change the password if you seeded a placeholder.
  5. Create the GMod server for pairing. In Servers → Add, or POST /api/servers/setup with { name, slug } as an authenticated owner. The response includes a one-time pairing token (not a permanent agent secret).
  6. Copy the pairing config. The dashboard shows configJson with pairingToken, apiUrl, and serverId. Paste it into the game host — do not invent a secret; the agent receives a permanent secret only after successful pairing.
  7. Install the agent. Copy kit-addons/modern_darkrp_control into garrysmod/addons/ on the game server.
  8. Configure the agent. Create garrysmod/data/modern_darkrp_control/config.json from the pairing payload (shape below). After first successful pair, the agent stores the issued secret locally; rotate later via Servers → Rotate secret if needed.
{
  "enabled": true,
  "apiUrl": "https://panel.example.com",
  "setupUrl": "https://panel.example.com/setup",
  "dashboardUrl": "https://panel.example.com",
  "serverId": "<server id from setup>",
  "pairingToken": "<one-time token from setup>",
  "secret": "",
  "heartbeatSeconds": 10,
  "commandPollSeconds": 5
}
  1. Start the dashboard. systemctl start moderndarkrp-dashboard, then confirm /api/health/ready returns 200.
  2. Start or restart GMod. The agent pairs (or reconnects) a few seconds after Initialize.

Two things bite here and both are silent:

- GMod loads Lua at boot. Installing or updating the addon on a running

server has no effect until it restarts.

- If apiUrl is a loopback or private address (127.0.0.1, localhost,

10.x, 192.168.x), srcds must be launched with -allowlocalhttp.

Without it GMod refuses the request internally and nothing reaches the

network. This is not needed for a public HTTPS apiUrl.

Run mdrp_status in the server console to see the agent version, timer

state, failure count and last error at any time.

  1. Verify the heartbeat. The server badge turns ONLINE within ~10 seconds; Overview shows a fresh timestamp.
  2. Verify live players. Join the server and confirm you appear on Players within one heartbeat.
  3. Verify the command queue. Send a ping command and confirm it reaches COMPLETED.
  4. Verify the Phase D/F adapters. Machines, Economy, Mining, Perks, and Progression should show connected adapters for the addons you actually run; missing addons correctly show as unavailable rather than fabricated data.
  5. Verify notifications and System. The System page should list every service as operational and no open incidents.
  6. Optionally enable Anti-Cheat Phase 1. Toggle it per server; detections are advisory and never auto-punish.
  7. Run the smoke tests against a staging copy (never production): npm run smoke:h.

7. Secret rotation

  1. Generate and install the new secret:
curl -X POST https://panel.example.com/api/servers/rotate-secret \
  -H "content-type: application/json" \
  -H "origin: https://panel.example.com" \
  -b "mdrp_session=<your session>" \
  -d '{"serverId":"<server id>","overlapMinutes":60}'
  1. The response contains agentSecret once. The previous secret keeps working for overlapMinutes.
  2. Update garrysmod/data/modern_darkrp_control/config.json on the game server with the new secret.
  3. Reload the agent (lua_run ModernDarkRP.LoadConfig() then wait for the next heartbeat, or restart the server at your next scheduled window).
  4. Verify: the server badge stays ONLINE and the next heartbeat succeeds with the new secret.
  5. Invalidate the old secret immediately by rotating again with "overlapMinutes": 0, or simply let the window expire.

Rotation is audited as server.secret_rotated; the secret itself is never written to the audit log.

8. Update procedure

  1. Back up the database first (see OPERATIONS.md § Backups). Take the backup before anything else.
  2. Review the migrations in the release: git log --oneline -- dashboard/prisma/migrations. Read any migration that drops or renames a column.
  3. Announce and enable maintenance mode if the update requires a restart.
  4. Pull the update and build: git pull && npm ci && npm run build.
  5. Apply migrations: npx prisma migrate deploy.
  6. Restart the dashboard: systemctl restart moderndarkrp-dashboard.
  7. Update the GMod addon: copy the new kit-addons/modern_darkrp_control over the installed addon.
  8. Restart or reload GMod. A full restart is safest; the agent uses stable hook and timer identifiers so a reload will not duplicate work, but pending timer.Simple callbacks from the previous load can still fire.
  9. Verify version compatibility. The Addons page marks the agent compatible only when its version matches the expected prefix (currently 0.7). An update_recommended badge means the game server is still running an older agent.
  10. Run the smoke tests and confirm heartbeat, players, and a ping command all succeed.
  11. Disable maintenance mode.

9. Rollback procedure

Rollback safety depends entirely on whether the release included a schema migration.

Code-only release (no migration): safe and fast. Check out the previous tag, npm ci && npm run build, restart. Revert the GMod addon to the matching version.

Release with additive migrations (new nullable columns, new indexes, new tables — including the Phase H migration): the old code runs fine against the new schema because it simply ignores the new columns. Roll back the code and leave the schema in place. Do not "un-migrate" just to tidy up.

Release with destructive migrations (dropped or renamed columns, narrowed types, new NOT NULL or UNIQUE constraints): not reversible by re-running Prisma. The only reliable path is restoring the pre-update database backup, which loses every write since the backup was taken. This is why step 1 of the update procedure is a backup.

Practical rule: treat forward migration as one-way. Test every update on a staging copy of production data before applying it live. If you must plan for a fast rollback, take the backup immediately before the migration and keep the game server in maintenance mode until you have verified the update, so the amount of data at risk stays small.

Agent rollback is independent: the dashboard accepts older agents (they show as update_recommended), except that agents older than 0.7.0 do not sign requests and will be rejected in production unless ALLOW_UNSIGNED_AGENT=true is temporarily set.

10. Deployment files

| File | Purpose |

|---|---|

| dashboard/.env.example | Placeholder environment template |

| dashboard/scripts/check-env.ts (npm run env:check) | Fail-fast production configuration validation |

| docs/DEPLOYMENT.md | This document |

| docs/OPERATIONS.md | Runbook, retention, backups, troubleshooting |

| docs/SECURITY.md | Threat model and hardening reference |

No Dockerfile or compose file is included. This stack is a single Node process plus PostgreSQL managed by systemd; containerizing it would add an image build and orchestration layer without solving a problem this deployment actually has. If your environment is already container-based, a standard node:20-slim image running npm run start with the same environment variables is sufficient.