Skip to content

Runbooks

Production incident procedures. For local problems see Troubleshooting.

First response

bash
kubectl get pods                                     # anything not Running?
kubectl get events --sort-by=.lastTimestamp | tail -30
kubectl top nodes && kubectl top pods

curl -sS -o /dev/null -w "%{http_code}\n" https://api.playpalz.gg/health
curl -sS -o /dev/null -w "%{http_code}\n" https://socket.playpalz.gg/health
curl -sS -o /dev/null -w "%{http_code}\n" https://www.playpalz.gg

Then Grafana at grafana.playpalz.gg:

text
{app=~"playpalz-.*"} | json | level >= 50

The API is down

Symptom: api.playpalz.gg returns 5xx or does not respond. Everything in the app breaks.

bash
kubectl get pods -l app=playpalz-api
kubectl describe pod <pod>
kubectl logs -l app=playpalz-api --tail=200
What you seeCauseAction
CrashLoopBackOffBad config or a startup exceptionRead the logs; check for a missing env var after a recent deploy
ImagePullBackOffRegistry auth or a bad tagCheck the registry-playpalzproduction pull secret
OOMKilled in describeMemory limit hitRaise limits, or find the leak
PendingNo schedulable nodekubectl top nodes; the pool autoscales 2–10
Pods Running, requests failingDatabase or Redis unreachableSee below

Immediate mitigation: if a recent deploy caused it, roll back — check out the last good commit, rebuild, push, restart. See Deploying.

The database is unreachable

Symptom: every service logs Prisma connection errors.

bash
doctl databases list
doctl databases get <database-id>
kubectl exec -it <api-pod> -- node -e "require('net').connect(25060,'<db-host>').on('connect',()=>{console.log('ok');process.exit(0)}).on('error',e=>{console.log(e.message);process.exit(1)})"

Check in order:

  1. Database status in the DigitalOcean console — maintenance, failover, or an incident.
  2. Firewalldigitalocean_database_firewall restricts access to the cluster. A new node pool or a recreated cluster can fall outside it.
  3. Connection exhaustiondb-s-2vcpu-4gb has a bounded connection limit, and three API replicas plus four other services each hold a pool. This is the most likely cause under load.
sql
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
SELECT pid, state, query_start, left(query, 80) FROM pg_stat_activity
  WHERE state = 'active' ORDER BY query_start;

Scaling replicas down temporarily relieves connection pressure. The durable fix is a connection pooler (PgBouncer, or DigitalOcean's managed pooler).

Redis is down

Symptom: realtime stops delivering, media stops processing, feed latency rises.

bash
kubectl get pods -l app=playpalz-redis
kubectl logs -l app=playpalz-redis --tail=100
kubectl exec -it <redis-pod> -- redis-cli -a <password> PING

Single replica with a 5 Gi PVC, so a restart loses in-flight state but not persisted data.

ImpactRecovery
BullMQ queueJobs persisted in Redis survive; in-flight jobs retry
Socket.IO adapterCross-pod fan-out stops entirely until it returns
Feed / trending cacheCache misses fall through to Postgres — slower, still correct
anansi payout lockA run cannot acquire the lock; it fails safe

After recovery, restart esu so its adapter reconnects cleanly:

bash
kubectl rollout restart deployment/playpalz-esu

Realtime is broken

Symptom: messages arrive only on refresh; presence and typing do nothing.

bash
kubectl get pods -l app=playpalz-esu
kubectl logs -l app=playpalz-esu --tail=200
curl https://socket.playpalz.gg/health

In Grafana:

text
{app="playpalz-esu"} | json | event = "socket:auth_failed"
{app="playpalz-esu"} |= "redis"
SymptomCause
Connect then immediate disconnectJWT_SECRET mismatch between api-secrets and what esu reads
Every join AUTHZ_DENIEDAPI_BASE_URL wrong, or the API is failing the authz endpoints
Connects fine, no eventsRedis adapter down, or the API cannot reach /admin/emit/*
Sporadic dropsIngress timeout, or the sticky-session cookie not being honoured

Check the API side too — it is the producer:

text
{app="playpalz-api"} |= "REALTIME_SERVICE_URL"

Media is not processing

Symptom: uploads succeed but never get thumbnails; posts stay pending.

bash
kubectl get pods -l app=playpalz-ogun
kubectl logs -l app=playpalz-ogun --tail=200

kubectl exec -it <redis-pod> -- redis-cli -a <pw> LLEN 'bull:media-processing:wait'
kubectl exec -it <redis-pod> -- redis-cli -a <pw> LRANGE 'bull:media-processing:failed' 0 -1
Queue stateMeaning
wait growing, worker logs silentWorker is not consuming — restart ogun
failed growingProcessing is erroring — read the error and Media.errorMessage
Queue empty, media still pendingThe API is not enqueuing — check API logs

Video specifically: Media.status only becomes ready when Mux calls the webhook. Confirm the webhook is registered and reaching api.playpalz.gg/api/v1/webhooks/mux.

Remember ogun has no health probe, so Kubernetes will not restart a wedged worker. Restart it yourself:

bash
kubectl rollout restart deployment/playpalz-ogun

A payout run failed

Highest-severity class of incident — real money.

bash
kubectl logs -l app=playpalz-anansi --since=48h

curl -H "Authorization: Bearer $ADMIN_SECRET" \
  http://localhost:4005/admin/payout/status?period=2026-01

The 25th run failed

  1. Check the logs for the cause — a Stripe error, a database error, or a lock conflict.
  2. GET /admin/payout/preview to see what would be calculated. It writes nothing.
  3. If no Payout rows were created, trigger POST /admin/payout/calculate.
  4. If rows were partially created, the idempotency check will skip a re-run. Reconcile manually before re-triggering.

The 1st run failed

  1. GET /admin/payout/status for the period.
  2. POST /admin/payout/retry re-attempts only status=failed transfers — safe to run.
  3. Persistent failures are usually creator-side: incomplete Stripe Connect onboarding, or a restricted account. Check the Stripe dashboard for that connected account.

A creator was paid twice

Stop and investigate before doing anything else.

  1. Find the duplicate Ledger entries and Payout rows.
  2. Check the idempotencyKey values — if they differ, the guard was bypassed and that is the bug.
  3. Reverse in Stripe, and record an ADJUSTMENT ledger entry. Never edit or delete an existing ledger row.
  4. Verify anansi is still at replicas: 1.

TLS certificate expired or failing

bash
kubectl get certificate
kubectl describe certificate playpalz-tls
kubectl logs -n cert-manager -l app=cert-manager --tail=100

Usual causes: DNS changed and the HTTP-01 challenge can no longer reach the ingress; port 80 blocked; or Let's Encrypt rate limiting after repeated failures. See DNS & TLS.

Everything is unreachable

bash
kubectl cluster-info
kubectl get nodes
doctl kubernetes cluster list
kubectl get service -n ingress-nginx
dig api.playpalz.gg +short

Check, in order: the DigitalOcean status page, node health, the LoadBalancer's external IP, and whether DNS still resolves to it.

Restoring the database

bash
doctl databases backups list <database-id>
doctl databases restore <database-id> --backup-created-at <timestamp>

Managed Postgres takes daily backups with point-in-time recovery. A restore creates a new cluster — you then have to update database-credentials and restart every service that uses it.

Before restoring, be certain: everything written since the restore point is lost.

Escalation

SituationDo
Money moved incorrectlyStop anansi, preserve the ledger, involve whoever owns finance
Suspected data breachRotate JWT_SECRET (logs everyone out), rotate all sealed secrets, preserve logs
Total outage > 15 minCheck the DigitalOcean status page; open a support ticket
Data lossStop writes, assess the blast radius, restore from a snapshot

After an incident

  1. Write down the timeline and the root cause.
  2. Add the failure mode to this page if it is not here.
  3. Add the monitoring that would have caught it earlier — see Observability.
  4. Fix the underlying cause, not just the symptom.

Internal documentation — PlayPalz platform