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 -dThis is the one install path that uses your own Bun. Keep it inside engines.bun in package.json (>=1.4.0 <1.5.0 for this release): the Docker image and the Desktop server bundle carry that Bun themselves, and it is the only one releases are built and tested on. Bun does not enforce the range, so the server checks it at boot (server/bunVersion.ts) and logs [server] Bun <version> is outside the supported range … before continuing; bun run dev refuses a Bun older than 1.4.1 outright, because its Vite proxy needs that release to forward the editor's WebSocket. bun upgrade moves an older install forward.
The 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).
Update a direct install by pulling, reinstalling dependencies, rebuilding, and restarting the process. bun install is not optional: a release can add a server dependency (0.0.18 added jsdom for the richtext sanitizer), and without it the server exits on startup with Cannot find module.
git pull
bun install --frozen-lockfile
bun run build
# then restart the bun process under your supervisorWrap 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