Cariosan

Backup & upgrade

Day-2 operational procedures — backup, restore, upgrade, rollback, rotate secrets.

8 min readUpdated Mar 12, 2026

Two data stores hold state that matters: Postgres (everything structured — users, channels, messages, webhook deliveries) and MinIO (attachment files). Redis only holds ephemeral state (rate-limit counters, presence markers, typing) — you can throw it away and restart without data loss.

Postgres

Daily dump

terminal
docker compose exec -T postgres \
    pg_dump -U cariosan -d cariosan --format=custom \
    > "/backups/cariosan-$(date +%F).dump"

Compression — chat content compresses well (~5-10× on typical traffic). Rotate weekly or monthly based on compliance needs.

Restore

terminal
docker compose down cariosan-server
docker compose exec -T postgres \
    pg_restore -U cariosan -d cariosan --clean < cariosan-YYYY-MM-DD.dump
docker compose up -d cariosan-server

Stop the server first — taking cariosan-server down before the restore avoids racing with live INSERTs. Postgres will gladly let you restore over a busy database, but you'll get a corrupted final state.

Point-in-time recovery

The bundled Postgres container doesn't ship WAL archiving. If RPO matters, switch to a managed Postgres (RDS, Neon, Supabase) that handles PITR natively, or replace the container with a postgres:15 image configured with archive_mode=on.

MinIO

Mirror to another bucket

terminal
docker run --rm --network cariosan_cariosan \
    -v /backups/minio:/target minio/mc sh -c '
        mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
        mc mirror --remove local/cariosan-attachments /target
    '

For geographical redundancy, replace /target with a remote S3 bucket alias. mc mirror --remove keeps the backup in sync with deletions on the source.

Upgrade

Migration: idempotent sends

This release adds a nullable client_message_id column and a partial unique index (idx_messages_user_client_message_id) to messages, so clients can retry a send without creating a duplicate. The column is added instantly; the index is built on first boot with a plain CREATE UNIQUE INDEX, which blocks writes to messages while it builds. In our test (1,000,000 messages, 8 concurrent writers) that was about 0.15 seconds, so most deployments can ignore it. On a very large or busy table you can pre-build it without blocking writes; see the runbook in docs/operations/message-idempotency-migration.md. Roll the server image back before removing the column or index, because the new image requires both.

Migration: new messages index

This release adds an index (idx_messages_channel_created_id on messages (channel_id, created_at, id)) that backs message-history pagination. It is created automatically on first boot with a plain CREATE INDEX, which blocks writes to messages while it builds: a moment on a young database, but about 10 seconds for a million messages in our test. On a large or busy table build it yourself first without blocking writes, then deploy and the migration finds it already there:

terminal
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
  -c "SET statement_timeout = 0; CREATE INDEX CONCURRENTLY idx_messages_channel_created_id ON messages (channel_id, created_at, id);"
# must print t; if it prints f the build failed — drop it with DROP INDEX CONCURRENTLY and retry
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
  -c "SELECT indisvalid FROM pg_index WHERE indexrelid = 'idx_messages_channel_created_id'::regclass;"

A failed concurrent build leaves an invalid index that the migration silently skips, so always check the result. The older (channel_id, created_at) index is a prefix of the new one and is intentionally kept; you can drop it later once you have confirmed query plans on your own data.

Pin the image tag in .env (see docker-compose), bump to the next release, and pull:

terminal
# Edit .env: CARIOSAN_IMAGE=ghcr.io/cariosan/server:v0.2.0
docker compose pull
docker compose up -d

The new container applies pending migrations on boot. It only starts serving traffic after its healthcheck passes (~10-15s start period), so there's a brief cutover window during which the old container is stopping and the new one is migrating.

Rollbacks are safe — MVP migrations are additive only (no DROP / ALTER COLUMN). Rolling back is just CARIOSAN_IMAGE=...:v0.1.0 + docker compose up -d.

Rotating secrets

CARIOS_JWT_SECRET

Restarting with a new value invalidates every outstanding client JWT in one go. Users must sign in again; the client SDK surfaces this as UNAUTHORIZED.

Coordinate with your frontend — for a smooth rotation, push a new token-refresh flow first, wait a deploy, then rotate. Otherwise active users see a logout storm.

Workspace API secret

Use the server CLI:

terminal
docker compose exec cariosan-server /cariosan workspace rotate-secret \
    --api-key pk_live_abc

Update your backend first — the old secret stops working immediately. Set the new CARIOS_API_SECRET in your backend env before running rotate-secret, not after.

MINIO_ROOT_PASSWORD

Redeploy with the new password in .env. Existing presigned URLs (15-minute TTL) remain valid until expiry; new uploads use the new credentials.

POSTGRES_PASSWORD

Changing env var alone is NOT enough — Postgres doesn't re-hash its password from the env var on restart. Either run ALTER USER ... WITH PASSWORD ... from inside Postgres and then update .env, or recreate the postgres_data volume from a fresh backup under the new credentials.

Monitoring

Basic health signal: GET /healthz returns 200 when the server is up. The Docker HEALTHCHECK directive uses the built-in /cariosan healthcheck subcommand — don't try to replace it with curl, the distroless image doesn't ship one.

Rich observability — Prometheus metrics at /metrics, structured JSON logs to stdout, request traces — ships in the MVP server.

Realtime delivery health

Messages are persisted before their realtime frame is sent, so a lost frame never loses data; it only leaves connected clients stale until they re-sync. The following counters distinguish lost channel events from deliberately skipped ephemeral frames:

MetricMeaning
cariosan_realtime_events_dropped_total{reason}Realtime frames not delivered. reason="inbox_full": a per-channel subscriber buffer overflowed (a consumer fell behind). reason="publish_failed": the Redis bus rejected a publish. reason="backpressure": an expendable typing, presence or receipt frame was skipped for one connection; this does not by itself mean a chat message was lost. reason="inbox_shed": the same kind of frame was skipped because the channel's subscriber buffer was three-quarters full; it is not a loss that needs repair and triggers no resync. reason="authz_unavailable": an event was withheld because channel membership could not be checked (database slow or unreachable); clients are told to re-sync, and a sustained non-zero rate means the database pool is saturated.
cariosan_receipts_coalesced_total{kind}Reports absorbed into a trailing update, with kind="delivered" or kind="read". Expected to rise during bursts; not a message-loss counter.
cariosan_realtime_resync_signals_totalchannel.resync signals sent to connected clients as a result.

In a healthy deployment both stay at zero. A sustained non-zero rate() of publish_failed means Redis is unreachable or overloaded; inbox_full means one server is saturated. Example alert:

prometheus-rules.yml
- alert: CariosanRealtimeLoss
  expr: sum(rate(cariosan_realtime_events_dropped_total{reason=~"inbox_full|publish_failed|authz_unavailable"}[5m])) > 0
  for: 10m
  annotations:
    summary: "Realtime frames are being dropped; clients are re-syncing from history"

Clients recover automatically (see Staying in sync); frames for clients on other servers cannot be signalled while the Redis bus itself is down, and those clients recover on their next reconnect.

When Redis is down, sending a message fails with 503 SERVICE_UNAVAILABLE and a Retry-After header (the rate limiter needs Redis). The rate-limit check is bounded to 2 seconds, so an unreachable or unresponsive Redis produces the 503 within about 2 seconds (a stopped Redis refuses the connection and answers at once). Nothing is stored, so the client can retry safely; the SDK marks the error retryable. Messages already stored are unaffected. A Redis publish that takes longer than one second counts as failed (publish_failed) and the channel's clients re-sync from history.

Was this page helpful?

On this page