Configuration Reference
All configuration is done through environment variables on the server service. The web app is a static SPA that only needs VITE_SERVER_URL at build time.
Required variables
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string. When using built-in Postgres: postgresql://bittery:${DB_PASSWORD}@postgres:5432/bittery |
JWT_SECRET | Secret key for signing authentication tokens. Generate with openssl rand -hex 32 |
DOMAIN | Your domain name (used by Caddy for TLS) |
BITTERY_RELEASE | Exact release tag used by both the server and web images |
Danger
Never reuse JWT_SECRET across instances or commit it to version control. Rotating this secret will invalidate all active sessions.
Transport security
Public server and object-storage URLs must use HTTPS. Loopback HTTP remains available for local development. A non-loopback HTTP deployment requires both an operator-enabled insecure transport capability and confirmation on each affected client account; a global client preference is not sufficient. This exception should only be used on a network whose confidentiality and integrity are managed outside Bittery.
Server settings
| Variable | Default | Description |
|---|---|---|
BITTERY_MODE | self-hosted | Operating mode. Use self-hosted for self-hosted deployments |
BITTERY_CLOUD_PUBLIC_SIGNUP | true | Cloud-only flag. Set false on hosted Bittery Cloud to make signup invite-only |
BITTERY_CLOUD_BILLING_ENABLED | true | Cloud-only flag. Set false on hosted Bittery Cloud to disable Stripe billing |
BITTERY_ALLOW_INSECURE_HTTP | false | Advertise operator permission for non-loopback HTTP. Each client account must still confirm before sending credentials |
CORS_ORIGIN | https://${DOMAIN} | Comma-separated list of allowed origins for API requests |
REQUEST_TIMEOUT_SECONDS | 30 | Maximum duration for ordinary HTTP requests. The long-lived sync event stream is exempt |
DATABASE_MAX_CONNECTIONS | 5 | Maximum Postgres connections per server instance. Required when NODE_ENV=production |
DATABASE_ACQUIRE_TIMEOUT_SECONDS | 5 | Maximum wait for a Postgres pool connection before failing the request |
TRUST_PROXY_MODE | none | How to read client IPs. Options: none, cloudflare, forwarded. See the warning under Rate limiting |
PORT | 3000 | Port the API server listens on |
MIGRATIONS_FOLDER | /app/migrations | Path to SQL migration files |
Size DATABASE_MAX_CONNECTIONS per server instance, not for the deployment as a whole. For a
managed database such as Railway Postgres, start with
floor((connection limit - reserved/admin connections) / maximum server replicas), then cap that
number to the concurrency the instance can actually use. Leave several connections available for
migrations and operator access, and verify that all replica pools together stay below the provider's
limit.
Rate limiting
The per-IP limits key on the client address resolved via TRUST_PROXY_MODE. With
the default none, that is the TCP peer address, which a caller cannot forge.
Only set cloudflare or forwarded if the server is genuinely behind a proxy
that overwrites CF-Connecting-IP / X-Forwarded-For on every request. If
the server is also reachable directly, those headers are attacker-controlled and
rotating them hands out a fresh per-IP budget per request, defeating the limits
below. Conversely, leaving it at none behind a proxy makes every request appear
to come from the proxy, collapsing all clients onto one shared budget.
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_ADAPTER | auto | Rate limiter backend: auto, postgres, or redis. auto uses Redis when RATE_LIMIT_REDIS_URL is set, otherwise Postgres. redis requires RATE_LIMIT_REDIS_URL (fails at startup if unset). There is no in-memory backend |
RATE_LIMIT_REDIS_URL | — | Redis/Valkey URL for the Redis rate-limiting backend |
SHARE_LINK_DAILY_LIMIT | 50 | Maximum shared links a user can create per fixed 24-hour window |
Tuning limits
Override the maximum request counts below. The time windows are fixed in code; only the counts (and the two lockout durations) are configurable.
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_LOGIN_IP | 20 | Login attempts per 15 minutes per IP |
RATE_LIMIT_LOGIN_EMAIL | 10 | Login attempts per 15 minutes per email |
RATE_LIMIT_SIGNUP_IP | 10 | Signups per hour per IP |
RATE_LIMIT_SIGNUP_EMAIL | 5 | Signups per hour per email |
RATE_LIMIT_SIGNUP_VERIFY_REQUEST | 5 | Signup verification code requests per hour, counted independently per email and per IP |
RATE_LIMIT_SIGNUP_VERIFY_MAX | 10 | Failed signup verification code guesses per email before lockout |
RATE_LIMIT_SIGNUP_VERIFY_LOCK_MINUTES | 15 | Signup verification lockout duration in minutes |
RATE_LIMIT_RECOVERY_REQUEST | 5 | Recovery requests per hour |
RATE_LIMIT_RECOVERY_VERIFY_MAX | 5 | Recovery verification attempts before lockout |
RATE_LIMIT_RECOVERY_LOCK_MINUTES | 15 | Recovery lockout duration in minutes |
RATE_LIMIT_SHARE_EMAIL_VERIFY_MAX | 5 | Failed share-link email verification attempts before lockout |
RATE_LIMIT_SHARE_EMAIL_VERIFY_LOCK_MINUTES | 15 | Share-link email verification lockout duration in minutes |
RATE_LIMIT_AUTH_IP | 30 | Requests per 15 minutes per IP for other auth endpoints |
RATE_LIMIT_ACCOUNT_MUTATION | 5 | Attempts per hour per user and operation for email, password, secret-key, and account-deletion mutations |
RATE_LIMIT_DEVICE_REVOKE | 10 | Session revocations per 15 minutes per authenticated device |
RATE_LIMIT_DEVICE_RENAME | 30 | Session renames per 15 minutes per authenticated device |
Note
RATE_LIMIT_SIGNUP_VERIFY_MAX counts failed guesses against the email address, not against a single code, and requesting a new verification code does not reset it. That is deliberate: the per-code counter in the database does reset with every new code, so without this an attacker could buy a fresh set of guesses just by asking for another email. The count clears only on a successful verification or once the RATE_LIMIT_SIGNUP_VERIFY_LOCK_MINUTES lockout has elapsed — a locked-out user has to wait the window out, and sending themselves another code will not help.
Object storage (S3-compatible)
Required only if you enable the storage profile for file attachments.
| Variable | Default | Description |
|---|---|---|
BITTERY_STORAGE_ENDPOINT | — | S3 endpoint URL (e.g. http://minio:9000) |
BITTERY_STORAGE_BUCKET | — | S3 bucket name |
BITTERY_STORAGE_ACCESS_KEY_ID | — | S3 access key |
BITTERY_STORAGE_SECRET_ACCESS_KEY | — | S3 secret key |
BITTERY_STORAGE_REGION | auto | S3 region |
BITTERY_STORAGE_CDN_URL | — | Public CDN URL for uploaded files |
Cache (Redis / Valkey)
| Variable | Default | Description |
|---|---|---|
REDIS_URL | — | Redis/Valkey connection URL for pub/sub and caching |
Built-in PostgreSQL
These apply when using the builtin-db profile:
| Variable | Description |
|---|---|
DB_PASSWORD | Password for the built-in PostgreSQL instance |
POSTGRES_DB | Database name (default: bittery) |
POSTGRES_USER | Database user (default: bittery) |
Web app (build-time only)
| Variable | Description |
|---|---|
VITE_SERVER_URL | API server URL. Inlined at build time by Vite |
VITE_BILLING_MARKETING_ENABLED | Marketing-site flag for hosted pricing and billing documentation. Defaults to disabled |
Note
Since VITE_SERVER_URL is inlined at build time, changing it requires rebuilding the web image. For Docker Compose deployments with Caddy, the web app and server share the same domain, so this is not needed.