This guide covers Docker Compose installs on a single VPS.
The VPS stack uses the same production image as managed platforms. Compose only supplies local persistence, an optional bundled Postgres service, and an optional Caddy TLS proxy.
| Mode | Source-build command | Containers | Persistent volumes |
|---|---|---|---|
| SQLite | docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.build.yml up -d --build |
app |
data, uploads |
| Postgres | docker compose -f compose.prod.yml -f compose.build.yml up -d --build |
app, postgres |
postgres_data, uploads |
| SQLite + TLS | docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.tls.yml -f compose.build.yml up -d --build |
app, caddy |
data, uploads, caddy_data |
| Postgres + TLS | docker compose -f compose.prod.yml -f compose.tls.yml -f compose.build.yml up -d --build |
app, postgres, caddy |
postgres_data, uploads, caddy_data |
SQLite is the default for most single-site installs. Postgres is for multiple simultaneous admin writers, horizontal app scale, or operators who already want Postgres.
When using a published image, set INSTATIC_IMAGE and omit compose.build.yml plus --build.
Before adding AI provider credentials, saving plugin secret settings, or enabling TOTP MFA in production, set INSTATIC_SECRET_KEY to the output of bun run scripts/generate-secret-key.ts.
- Download
instatic-<version>-release-bundle.tar.gzfrom the GitHub Release. - Unpack it on the server.
- Choose SQLite or Postgres.
SQLite:
INSTATIC_IMAGE=ghcr.io/corebunch/instatic:<version> docker compose -f compose.prod.yml -f compose.sqlite.yml up -dPostgres:
cp .env.production.example .env
# Set POSTGRES_PASSWORD and INSTATIC_SECRET_KEY in .env.
INSTATIC_IMAGE=ghcr.io/corebunch/instatic:<version> docker compose -f compose.prod.yml up -dInstall Docker Engine and Docker Compose on the VPS. If using TLS, point a domain's DNS A/AAAA records at the server and open ports 80 and 443.
Use a source checkout:
git clone https://github.com/CoreBunch/Instatic.git
cd instaticFor plain SQLite without TLS, source builds use compose.prod.yml, compose.sqlite.yml, and compose.build.yml. Image-pull installs use only compose.prod.yml and compose.sqlite.yml, but they still need the Compose files from a checkout or release bundle.
For reversible server secrets such as AI credentials, plugin secret settings, and TOTP MFA seeds, copy the env template and set INSTATIC_SECRET_KEY first:
cp .env.production.example .env
bun run scripts/generate-secret-key.tsPaste the printed key into .env as INSTATIC_SECRET_KEY.
Run:
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.build.yml up -d --buildThis starts one app container. compose.sqlite.yml disables the Postgres service and sets:
DATABASE_URL=sqlite:/app/data/cms.dbPersistent data:
| Volume | Mount path | Contents |
|---|---|---|
data |
/app/data |
SQLite database |
uploads |
/app/uploads |
Media, fonts, plugins, published artefacts |
Open:
http://server-ip:3001/adminThe first visit creates the site and admin account.
Copy the env template:
cp .env.production.example .envEdit .env and set a real password:
POSTGRES_PASSWORD=replace-with-a-long-random-password
INSTATIC_SECRET_KEY=replace-with-output-of-generate-secret-keyGenerate one with:
openssl rand -hex 24Start the stack:
docker compose -f compose.prod.yml -f compose.build.yml up -d --buildThis starts app and postgres. compose.prod.yml sets the app's DATABASE_URL to the bundled Postgres service:
postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}Persistent data:
| Volume | Mount path | Contents |
|---|---|---|
postgres_data |
/var/lib/postgresql/data |
Postgres data directory |
uploads |
/app/uploads |
Media, fonts, plugins, published artefacts |
Add compose.tls.yml when the VPS has a public domain. Set these in .env:
DOMAIN=cms.example.com
LETSENCRYPT_EMAIL=ops@example.com
PUBLIC_ORIGIN=https://cms.example.comCaddy terminates TLS and forwards plain HTTP to the container, so the container's own request URL is http://app:3001. PUBLIC_ORIGIN is how Instatic knows the real public origin for its CSRF check — set it to https:// plus your DOMAIN. (Leave PUBLIC_ORIGIN unset only for plain-HTTP installs with no proxy in front.)
Run SQLite + TLS:
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.tls.yml -f compose.build.yml up -d --buildRun Postgres + TLS:
docker compose -f compose.prod.yml -f compose.tls.yml -f compose.build.yml up -d --buildSee tls-caddy.md for the Caddy details.
Check status:
docker compose -f compose.prod.yml ps
curl http://localhost:3001/healthView logs:
docker compose -f compose.prod.yml logs -f app
docker compose -f compose.prod.yml logs -f postgres # Postgres installs onlyUpdate a source-build install:
git pull
docker compose -f compose.prod.yml -f compose.sqlite.yml -f compose.build.yml up -d --buildFor Postgres source-build installs, omit compose.sqlite.yml:
git pull
docker compose -f compose.prod.yml -f compose.build.yml up -d --buildUpdate an image-pull install:
docker compose -f compose.prod.yml pull app
docker compose -f compose.prod.yml up -dSQLite image-pull installs include the SQLite override:
docker compose -f compose.prod.yml -f compose.sqlite.yml pull app
docker compose -f compose.prod.yml -f compose.sqlite.yml up -dThe CMS runs directly on the host without Docker. From a source checkout:
bun install
bun run build
DATABASE_URL=sqlite:./data/cms.db \
STATIC_DIR=./dist \
UPLOADS_DIR=./uploads \
INSTATIC_SECRET_KEY=replace-with-output-of-generate-secret-key \
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 \
PORT=3001 \
bun run server/index.tsReplace DATABASE_URL with a Postgres connection string for Postgres mode. STATIC_DIR must point at the built admin SPA (dist/ after bun run build).
Wrap the command in a process supervisor (systemd, pm2, supervisord) for auto-restart on crash and on server boot. Put an HTTPS-capable reverse proxy (Caddy, Nginx, Cloudflare Tunnel) in front for TLS, and set PUBLIC_ORIGIN=https://your-domain so the CSRF origin check matches the public URL even though the proxy hands the Bun process plain HTTP. TRUSTED_PROXY_CIDRS is independent of CSRF: set it to the proxy's source CIDR only if you want real client IPs in audit logs and rate-limit keys, and leave it empty if the app is directly exposed.
docker compose down stops containers and keeps named volumes.
docker compose down -v deletes named volumes. For Instatic that means deleting the CMS database and uploaded media. Use it only when intentionally wiping the install.
Backups are covered in backup-restore.md.
- deployment/README.md — deployment overview
- docker-image.md — generic Docker image contract
- tls-caddy.md — HTTPS overlay
- backup-restore.md — backup and restore procedures
compose.prod.yml— production Compose basecompose.sqlite.yml— SQLite overridecompose.tls.yml— Caddy TLS override