Skip to content

Managed Postgres / Redis / S3

The in-compose Postgres / Redis / SeaweedFS are fine for a one-box deploy or a small operator. When you outgrow them (or want managed backups, HA failover, regional replication), Scani makes no assumption about where its dependencies live. Comment out the compose service and update the corresponding env vars.

Any Postgres 16+ instance. The schema works with any vanilla distribution.

ProviderNotes
NeonServerless. Set ?sslmode=require and POSTGRES_POOL_MAX=5 if the URL includes ?pgbouncer=true (Neon’s default endpoint pools through PgBouncer).
RenderStandard. ?sslmode=require.
AWS RDS / AuroraStandard. ?sslmode=require.
SupabaseUse the direct connection string for migrations, the pooled one for runtime (or set POSTGRES_POOL_MAX=5).
Self-hostedAnything Postgres 16+.
DATABASE_URL=postgres://user:pass@host:5432/scani?sslmode=require
POSTGRES_POOL_MAX=5 # only if behind a connection pooler

Every sslmode except disable verifies the server. The api, worker, data-provider and migrate images connect with verify-full whatever the URL says, because sslmode=require on its own encrypts without checking who answered. Neon, Supabase and Render present publicly trusted certificates and need nothing more. A server behind a private CA — AWS RDS and Aurora, or your own — needs its CA bundle mounted and named in two variables, with the URL left as it is:

NODE_EXTRA_CA_CERTS=/certs/rds-global-bundle.pem # api, worker, data-provider, migrate
PGSSLROOTCERT=/certs/rds-global-bundle.pem # the worker's pg_dump backup

Without them the connection is refused with unable to verify the first certificate. Only sslmode=disable skips TLS, and only an in-network Postgres should use it.

Then comment out the postgres service in docker-compose.prod.yml:

# services:
# postgres:
# image: postgres:16-alpine
# ...

Apply migrations explicitly before each deploy. The scani/migrate image is a pre-built one-shot that applies both the application schema (Drizzle) and the job-queue schema (BullMQ) — no workspace clone, no Bun install on your side.

Terminal window
docker run --rm \
-e DATABASE_URL="$DATABASE_URL" \
scani/migrate:${SCANI_IMAGE_TAG:-latest} \
/app/migrate --allow-remote your-postgres-host.example.com

--allow-remote <host> is required here and the host has to match the one in DATABASE_URL — a managed database is never on loopback, so without it the container refuses and exits 1 having changed nothing. That guard is what stops a stale connection string rewriting the wrong database.

For a Kubernetes deploy, wrap it in a Job that runs before the api Deployment rolls out:

apiVersion: batch/v1
kind: Job
metadata:
name: scani-migrate-{{ .Values.image.tag }}
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: scani/migrate:{{ .Values.image.tag }}
args: ["/app/migrate", "--allow-remote", "your-postgres-host.example.com"]
env:
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: scani, key: DATABASE_URL } }

For a CI-based deploy, run it as a step before the api/worker rollout. The runner is idempotent — already-applied migrations are skipped — so re-running on every deploy is safe and cheap.

Always pin the same tag you’re using for your other scani/* images. Mixing scani/migrate:1.2.0 with scani/api:1.3.0 is unsupported.

See Production with docker-compose → Apply migrations for the compose-based variant and the “what if you forget” failure modes (api’s /readyz returns 503, worker restart-loops).

Any Redis 7+ instance. Cluster mode is supported via standard node-redis behaviour.

Redis is required, but the job queue is not on it — BullMQ runs on the Postgres backend, so job state lives in the bullmq schema of the database above. What Redis carries is realtime SSE pub/sub, job-lifecycle events pushed to the UI, the api’s request rate limiter, the shared upstream-provider rate limiter and the portfolio-value cache. All of it is ephemeral and regenerates.

ProviderNotes
UpstashTLS endpoint. rediss:// URL.
Redis CloudTLS endpoint.
AWS ElastiCacheIn-VPC. Standard redis:// URL.
Self-hostedAnything Redis 7+. Persistence is optional — see below.
REDIS_URL=rediss://default:pass@host:6379

Then comment out redis in docker-compose.prod.yml.

Any S3-compatible store works.

ProviderNotes
Cloudflare R2No egress fees. S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com.
AWS S3Region-specific endpoint.
Backblaze B2S3_ENDPOINT=https://s3.<region>.backblazeb2.com.
SeaweedFS (self-hosted, scaled out)Same as compose-managed, just point at a remote instance.
S3_ENDPOINT=https://<endpoint>
S3_PUBLIC_ENDPOINT=https://<public-endpoint> # often same as above
S3_ACCESS_KEY_ID=<key>
S3_SECRET_ACCESS_KEY=<secret>
S3_BUCKET=scani-uploads

Then comment out seaweedfs. Create the bucket yourself before first boot — there’s no init container for managed providers.

ProviderVariables
Any SMTP serverSMTP_URL, SMTP_FROM.
FastmailFASTMAIL_API_TOKEN (JMAP). Takes precedence over SMTP.
Postmark / SendGrid / MailgunUse their SMTP relay or set up a transactional API. SMTP is the simplest path.

Tier 1 sends authentication email locally using your SMTP or Fastmail configuration. Tier 2 sends fixed authentication templates through Scani Cloud, with no local mail-provider key. Arbitrary email sending and cloud storage are not available to customer cloud keys.

S3_PUBLIC_ENDPOINT is what gets baked into presigned URLs the browser uses. For compose-managed SeaweedFS, S3_ENDPOINT is http://seaweedfs:8333 (server-to-server) and S3_PUBLIC_ENDPOINT is http://localhost:9000 (the browser can’t resolve seaweedfs). For most cloud providers both URLs are the same.

No code change is required to use any of these. The schema doesn’t care. The application doesn’t care. The compose file is just one opinionated way to wire things together.