Skip to main content

Deployment

What you'll do: run the standalone OpenBucket container in production, with a persistent data volume, real secrets, and health checks — then put it behind TLS.

The minimal working deployment

A docker-compose.yml with one persistent volume is the fastest real deployment:

services:
openbucket:
image: ghcr.io/projectbay/openbucket:latest
restart: unless-stopped
ports:
- "9000:9000"
env_file:
- .env
environment:
DATA_DIR: /data # the mounted volume — overrides any DATA_DIR in .env
volumes:
- openbucket-data:/data
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:9000/api/admin/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3

volumes:
openbucket-data:

Generate the secrets, then start it:

# 1. argon2id hash for the admin password
node scripts/hash-password.mjs 'choose-a-strong-password'

# 2. fill in .env (see the required vars below), then:
docker compose up -d

OpenBucket now listens on http://localhost:9000:

  • S3 APIhttp://localhost:9000 (path-style)
  • Admin consolehttp://localhost:9000/admin
  • Admin APIhttp://localhost:9000/api/admin
  • Health / readiness/api/admin/health, /api/admin/ready
Where the image comes from

The standalone server image is published to ghcr.io/<owner>/openbucket on every v* release tag, with :latest, :{major}.{minor}, and exact :{version} tags (plus linux/amd64 and linux/arm64). Pin an exact version in production; use :latest only for trials. To build locally instead, use the repo's Dockerfile (docker compose up --build).

Required environment

OpenBucket validates its environment on boot and refuses to start if anything is missing or weak. The five required variables:

VariableNotes
DATA_DIRAbsolute path to the data volume (no trailing slash). Holds the SQLite DB, blobs, and sse.key.
JWT_SECRET≥ 32 chars, high-entropy. Signs admin JWTs. openssl rand -base64 48.
ADMIN_PASSWORD_HASHargon2id hash — node scripts/hash-password.mjs '<password>'.
ROOT_ACCESS_KEY_ID16–32 uppercase alphanumerics.
ROOT_SECRET_ACCESS_KEY≥ 32 chars, high-entropy.

Common optional ones:

VariableDefaultNotes
PORT9000HTTP listen port.
ADMIN_USERNAMEadminAdmin login.
OPENBUCKET_REGIONus-east-1Region reported to clients; match it in your SDK.
OPENBUCKET_SSE_KEYgeneratedbase64 of 32 bytes; else generated to <DATA_DIR>/sse.key.
KEY_ENCRYPTION_SECRETroot secretKEK for scoped sub-key secrets. Set it to decouple from the root key.
SHUTDOWN_DRAIN_MS30000Grace period to drain in-flight requests on SIGTERM.

The full, commented list lives in .env.example at the repo root (webhooks, replication, tiering, backups, metrics, analytics, and the DoS-guard limits). See the configuration reference for every key.

Weak secrets fail the boot, on purpose

A short, all-same-character, or known-placeholder value for JWT_SECRET / ROOT_SECRET_ACCESS_KEY is rejected at startup (not a warning — a hard refusal). Generate real random values.

The data volume

Everything OpenBucket persists lives under DATA_DIR — see storage layout. Two rules:

  • Mount all of DATA_DIR as one volume. The atomic write path renames from DATA_DIR/tmp into DATA_DIR/blobs, which is only atomic within a single filesystem. Don't bind-mount subdirectories separately.
  • Back it up as a unit. The SQLite DB and the blob tree must stay consistent with each other; snapshot them together (or use the built-in scheduled backups).

The container runs as a non-root user and exposes port 9000, so the volume must be writable by that user.

Behind a reverse proxy (TLS)

Terminate TLS at a proxy (nginx, Caddy, Traefik, an ALB) and forward to port 9000. OpenBucket speaks the S3 protocol, so the proxy must not buffer or rewrite request bodies, and must forward the client's scheme and host so SigV4 and presigned URLs verify:

server {
listen 443 ssl;
server_name storage.example.com;

# SigV4 signs the Host + path; presigned URLs sign the scheme too.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# Raw, unbuffered bodies — the S3 protocol needs them, and large uploads
# must not be spooled to disk by the proxy.
proxy_request_buffering off;
client_max_body_size 0; # don't cap upload size at the proxy

location / {
proxy_pass http://127.0.0.1:9000;
}
}

Set OPENBUCKET_ENDPOINT=storage.example.com so the store reports a DNS-safe public hostname for endpoint discovery.

Embedding under a path prefix

When you embed @openbucket/nestjs instead of running the container, everything mounts under mountPath (default /storage) — the S3 endpoint is http(s)://<host>/storage, the admin API is <mountPath>/api/admin, and the console is <mountPath>/admin. Your proxy just needs to forward that path prefix to your app. See the NestJS module reference.

Running behind a reverse proxy at a subpath

To expose OpenBucket at a subpathhttps://example.com/storage/… instead of a dedicated host or subdomain — set MOUNT_PATH on the container. Every surface moves under the prefix in lockstep: S3 becomes https://example.com/storage/<bucket>/<key>, the admin API https://example.com/storage/api/admin, the console https://example.com/storage/admin (its <base href> is rewritten to match), and the health/metrics endpoints follow too.

docker run -d --name openbucket \
-e MOUNT_PATH=/storage \
-e DATA_DIR=/data \
--env-file .env \
-v openbucket-data:/data \
ghcr.io/projectbay/openbucket:latest

Forward the prefix as-is — do not strip it, because SigV4 signs the full request path and the store verifies over the same path:

location /storage/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
client_max_body_size 0;
proxy_pass http://127.0.0.1:9000; # note: no trailing slash — keeps /storage/… intact
}

Point your S3 client's endpoint at https://example.com/storage with path-style addressing, and probe health at https://example.com/storage/api/admin/health.

MOUNT_PATH is the authoritative prefix

The prefix comes from MOUNT_PATH, not from a forwarded header — so a misconfigured proxy can't relocate the admin API out from under its guard. Leave MOUNT_PATH unset to keep everything at the root, exactly as before. It is the standalone twin of OpenBucketModule.forRoot({ mountPath }).

A minimal Kubernetes sketch

OpenBucket is single-node (one writer over SQLite + a local blob tree), so run it as a single-replica Deployment (or a StatefulSet) with a persistent volume — not a horizontally-scaled Deployment.

apiVersion: apps/v1
kind: Deployment
metadata:
name: openbucket
spec:
replicas: 1 # single-node — do NOT scale out
strategy:
type: Recreate # never two pods on the one RWO volume
selector:
matchLabels: { app: openbucket }
template:
metadata:
labels: { app: openbucket }
spec:
containers:
- name: openbucket
image: ghcr.io/projectbay/openbucket:latest
ports:
- containerPort: 9000
envFrom:
- secretRef: { name: openbucket-secrets } # JWT_SECRET, ROOT_*, ADMIN_PASSWORD_HASH…
env:
- name: DATA_DIR
value: /data
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet: { path: /api/admin/health, port: 9000 }
periodSeconds: 30
readinessProbe:
httpGet: { path: /api/admin/ready, port: 9000 }
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: openbucket-data # a ReadWriteOnce PVC

Call app.enableShutdownHooks() (the standalone image already does) so a SIGTERM from a rolling update drains in-flight requests before the process exits.

Health checks

Two unauthenticated probes are built in — orchestrators hit them without credentials:

EndpointMeaningResponse
GET /api/admin/healthLiveness — the process is up and the event loop responds.200 { "status": "ok", "uptime": <s> }
GET /api/admin/readyReadiness — the process can serve traffic now.200 { "status": "ready" }, or 503 { "status": "draining" } during shutdown.

Wire liveness to /api/admin/health and readiness to /api/admin/ready so a draining pod is pulled from the load balancer before it stops accepting work. (Under an embedded mountPath, these are at <mountPath>/api/admin/health and /ready.)

Next steps