Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

Install

Spillway is one process: a Node server that serves the API, the admin and the public site, with SQLite and uploaded files in one data directory. Run it with Docker or from source. For more than one instance, or a managed database, point DATABASE_URL at Postgres instead (see Postgres).

Requirements

  • Docker: any recent Docker Engine or Docker Desktop. Nothing else.
  • From source: Node 22 or newer (.nvmrc pins 22; 24 works too) and pnpm 10. Node 22 ships corepack, so corepack enable installs the pnpm pinned in package.json; npm install -g pnpm@10 is the alternative. The two native modules, better-sqlite3 and sharp, download prebuilt binaries during pnpm install for macOS (Apple Silicon and Intel), Linux (x64 and arm64, glibc and musl) and Windows on Node 22 and 24, so no compiler is needed there. A C++ toolchain (Xcode Command Line Tools on macOS, python3 make g++ on Debian) matters only when no prebuilt binary matches your platform or Node version.
  • About 200 MB of disk for the image, plus your data.
  • One CPU and 512 MB of RAM are enough for a marketing site.

Docker

docker run -d \
  --name spillway \
  -p 3000:3000 \
  -v spillway-data:/data \
  ghcr.io/kwall-llc/spillway-cms

Open http://localhost:3000/admin and complete the setup wizard (site name, your name, email, password).

The image sets DATA_DIR=/data, PORT=3000 and HOST=0.0.0.0, runs as the unprivileged node user, declares /data as a volume and has a Docker health check on GET /healthz. The database, uploads and the session secret all live in the spillway-data volume. Everything else uses the defaults in the table below. Pass -e NAME=value to change them.

The ghcr.io/kwall-llc/spillway-cms image is the intended distribution channel. The release workflow (.github/workflows/release.yml) builds it for linux/amd64 and linux/arm64 on every v* tag and pushes it to GHCR under the repository's own name (ghcr.io/<owner>/<repo>, lowercased) as vX.Y.Z, X.Y (moves with patch releases) and latest (the newest stable release); the GitHub release lists the digest. The canonical image name is an open decision (dev/open-decisions.md); this page, the fleet's default (FLEET_IMAGE) and the README all say ghcr.io/kwall-llc/spillway-cms and change together. Pin X.Y to follow fixes without surprises, or the digest for a reproducible deploy. Until the first tag is published (see docs/dev/releases.md), build it yourself:

docker build -t spillway .
docker run -d -p 3000:3000 -v spillway-data:/data spillway

The Dockerfile is a two-stage build: the build stage installs all workspace dependencies and runs pnpm run build; the runtime stage installs only the production dependencies of @spillway-cms/core and copies the compiled shared, blocks, core and admin output. The result is a node:22-bookworm-slim image with no compilers left in it.

Docker Compose

docker-compose.yml in the repository root builds the image from the checkout. Abridged (the file also wires the optional DATABASE_URL, REDIS_URL, SEARCH_URL and SEARCH_API_KEY variables and defines the postgres, redis and search profiles described below):

services:
  spillway:
    build: .
    image: spillway:local
    ports:
      - "3000:3000"
    environment:
      PORT: "3000"
      DATA_DIR: /data
      BASE_URL: ${BASE_URL:-http://localhost:3000}
      # SESSION_SECRET: change-me
      LOG_LEVEL: info
    volumes:
      - spillway-data:/data
    restart: unless-stopped

volumes:
  spillway-data:
docker compose up -d          # builds on first run
BASE_URL=https://www.example.org docker compose up -d   # behind a TLS proxy

To use a published image instead of building, replace build: . and image: spillway:local with image: ghcr.io/kwall-llc/spillway-cms.

From source

git clone https://github.com/kwall-llc/spillway-cms.git
cd spillway-cms
corepack enable      # once per machine: installs the pnpm version pinned in package.json
pnpm preflight       # checks Node, pnpm, the native modules, the data folder and the port, and says what to run next
pnpm install
pnpm build
pnpm start

Open http://localhost:3000/admin. The first visit lands on the setup wizard (site name, your name, email, a password of at least 8 characters); "Create site" signs you in and opens the dashboard. Until that first admin exists, every other admin URL redirects to /admin/setup. Budget about five minutes from clone to dashboard on a laptop: pnpm install takes under a minute on a normal connection, pnpm build about half a minute, the server starts in a second.

What each step does:

  • corepack enable makes the pnpm command available and, on first use, downloads the exact version from packageManager in package.json. Skip it if pnpm --version already prints 10.x.
  • pnpm preflight (scripts/doctor.mjs; not pnpm doctor, which is pnpm's own command) is optional and safe to run at any point; it fails only when Node is older than 22, pnpm is missing or older than 10, a native module does not load, or the data folder is not writable, and it warns when the port is taken or the build is missing.
  • pnpm install installs every workspace package. The two native modules run their install scripts (package.json allowlists them under pnpm.onlyBuiltDependencies, so pnpm 10 does not print its "Ignored build scripts" warning for them): better-sqlite3 downloads a prebuilt binary from its GitHub release, sharp installs the prebuilt @img/sharp-* package for your platform. Both are plain downloads; see Native modules if one fails.
  • pnpm build builds shared, blocks, admin (Vite, into packages/admin/dist), core (tsc, into packages/core/dist) and the fleet, sdk and docs-tools packages. The one warning it prints, "Some chunks are larger than 1200 kB", is Vite commenting on the admin bundle size and is expected.
  • pnpm start runs scripts/start.mjs, which loads .env when there is one and then starts packages/core/dist/server.js (running that file with node directly is the same without the .env step). The server applies the migrations, serves the admin from packages/admin/dist, and writes the database, uploads and session secret to ./data. The one log line it prints, Spillway 0.1.0 listening, shows the URL, the data folder and "installed": false until the wizard has run. Stop it with Ctrl+C.

pnpm quickstart runs pnpm install && pnpm build && pnpm preflight in one go. pnpm spillway <command> runs the CLI (see The spillway command). Copy .env.example to .env to change the port or the data folder; see Environment variables. For the development loop with hot reload see dev/contributing.md.

Two things a first run can also do:

  • Create the first admin from the terminal instead of the wizard: pnpm spillway create-user --email you@example.org --password 'at least 8 characters' --name 'You' (add --site-name 'Acme' to name the site). This creates the site and the user and marks the install as done, so /admin goes straight to the login screen.
  • Protect the wizard when the server is reachable by other people before you get to it (a shared host, a port forwarded through a firewall): start it with SETUP_TOKEN=<random string>; the wizard then has a "Setup token" field and POST /api/setup refuses requests without it. The token does nothing once the first admin exists.

Set NODE_ENV=production for a real deployment (the Docker image does): React then renders pages with its production build instead of the development build and its per-element checks, which is a quarter of the time a page takes to render (dev/benchmarks.md).

If /admin answers 503, the admin bundle is missing: run pnpm --filter @spillway-cms/admin build (or pnpm build). If pnpm start fails with Cannot find module '.../packages/core/dist/server.js', pnpm build has not run yet. If the port is taken, PORT=3001 pnpm start.

macOS

On a Mac (Apple Silicon or Intel) the steps above work as written once Node 22 is installed. Two ways to get it:

  • Homebrew: brew install node@22. The formula is keg-only, so add it to your PATH as brew tells you (echo 'export PATH="$(brew --prefix node@22)/bin:$PATH"' >> ~/.zshrc, then open a new terminal). It includes corepack, so corepack enable then works.
  • nvm: nvm install 22 && nvm use 22; the checkout's .nvmrc lets nvm use pick it up later.

Then corepack enable (or npm install -g pnpm@10). You do not need the Xcode Command Line Tools for Spillway itself: better-sqlite3 12.x and sharp 0.35 ship prebuilt darwin-arm64 and darwin-x64 binaries for Node 22 and 24, and pnpm install downloads them. If you see node-gyp output or an error mentioning make or clang, the prebuilt download failed (a proxy, an unusual Node version): install the tools with xcode-select --install and run pnpm rebuild better-sqlite3, or see Native modules.

Native modules

better-sqlite3 and sharp are the only packages with platform-specific binaries.

  • better-sqlite3 runs prebuild-install during pnpm install, which downloads better-sqlite3-v<version>-node-v<abi>-<platform>-<arch>.tar.gz from the package's GitHub releases; if that fails it falls back to node-gyp rebuild, which needs Python 3 and a C++ compiler. Behind a proxy that blocks github.com, set HTTPS_PROXY, or install the compiler.
  • sharp has no install script: pnpm install picks the matching @img/sharp-<platform>-<arch> and @img/sharp-libvips-* packages from the lockfile, which cover macOS, Linux and Windows.
  • pnpm 10 only runs install scripts for packages listed under pnpm.onlyBuiltDependencies in package.json; better-sqlite3 and esbuild are listed, so there is nothing to approve. If a future dependency adds one, pnpm install prints "Ignored build scripts" and pnpm approve-builds lets you allow it.
  • pnpm preflight loads both modules and prints the SQLite and libvips versions; pnpm rebuild better-sqlite3 rebuilds it in place after a Node upgrade (the binary is tied to the Node major version).

Environment variables

All are optional. The first table is what the server reads (packages/core/src/config.ts, plus the few that other modules read directly, each marked); the second is the fleet CLI. From a source checkout, pnpm start and pnpm spillway also read a .env file in the repository root (.env.example lists the common ones with their defaults); node packages/core/dist/server.js on its own reads only the shell environment. In Docker Compose, a .env next to docker-compose.yml fills the ${...} placeholders.

VariableDefaultWhat it does
PORT3000Port to listen on.
DATA_DIR./data (/data in the image)Directory for the SQLite file, the media/ upload folder and the session-secret file. Created on first boot.
DATABASE_URL<DATA_DIR>/spillway.dbWhere the database lives. A bare file path or sqlite:<path> is a SQLite file (:memory: gives a throwaway in-memory database, used by tests); postgres://user:pass@host:5432/db or postgresql://... switches the whole server to Postgres. See Postgres.
DATABASE_POOL_MAX10Postgres only: size of the connection pool per server process.
SESSION_SECRETgeneratedKey used to hash session tokens before they are stored. If unset, a random secret is generated on first boot and saved to <DATA_DIR>/session-secret (mode 0600), so sessions survive restarts. Set it explicitly when you run more than one container against the same data. At least 32 characters.
BASE_URLunsetPublic origin of the site, for example https://www.example.org, no trailing slash. When it starts with https:// the session cookie is marked Secure (with the __Host- prefix) and HSTS is sent; a trusted proxy's X-Forwarded-Proto: https does the same (see Reverse proxy). Also used for absolute URLs. Set it in production.
ADMIN_DISTpackages/admin/dist relative to the core packageFolder holding the built admin SPA. Change it only if you build the admin elsewhere.
LOG_LEVELinfoOne of debug, info, warn, error, silent. Warnings and errors go to stderr, the rest to stdout.
HOST0.0.0.0Interface to bind. Read in server.ts.
SMTP_URL, SMTP_FROMunsetMail relay for form email connectors and notification rules, e.g. smtp://user:pass@smtp.example.org:587?from=cms@example.org (smtps:// for implicit TLS; SMTP_FROM is an alternative to ?from=). Without it mail is logged instead of sent.
SPILLWAY_TICKER1The in-process clock that publishes scheduled content, merges scheduled workstreams, runs import jobs, polls translation jobs and delivers notifications every 30 s (and, hourly, prunes expired sessions, old audit rows and old 404 log entries). Set 0 when several server processes share one database and let cron run spillway run-scheduled instead. Read in server.ts.
TRUSTED_PROXIES (read in auth/client-ip.ts)unsetWhich peers may speak for the visitor through X-Forwarded-For: a comma-separated list of addresses or CIDRs (10.0.0.0/8, 172.16.0.0/12), the words loopback or private, fly (Fly.io's edge, which sets Fly-Client-IP), or * (every direct peer; only when a proxy is the sole route to the port). Unset, the socket address is the client, which behind a proxy means every visitor shares one rate-limit bucket. See Reverse proxy.
SETUP_TOKEN (read in routes/setup.ts)unsetWhen set, POST /api/setup (the first-admin wizard) needs the same value in the X-Setup-Token header; the wizard screen asks for it. The fleet generates one per site so a freshly provisioned site cannot be claimed by a stranger before setup runs. Unset it (or leave it) once the site is set up; it does nothing afterwards.
SPILLWAY_TRUSTED_MODULE_INSTALL (read in modules/system.ts)unset1 lets an admin install or enable trusted-tier (in-process JavaScript) modules from the admin. Off by default: see the trust model.
SPILLWAY_ALLOW_PRIVATE_URLS (read in forms/http.ts)unset1 lets webhooks, notification channels, AI base URLs and module HTTP calls reach private network addresses (an internal CRM behind the firewall). Off by default: outbound requests to loopback, link-local, RFC 1918 and other private ranges are refused. See security.md.
SPILLWAY_SAFE_MODE (read in modules/system.ts)unset1 boots without loading any module (same as --safe-mode).
SPILLWAY_CAPTCHA_VERIFY_URL (read in forms/captcha.ts)unsetTest hook: where CAPTCHA tokens are verified instead of the provider's siteverify endpoint (the test suite runs a local fake). Leave unset in production; the provider and keys are set under Site settings → Forms.
SPILLWAY_HUBSPOT_FORMS_URL (read in forms/connectors/hubspot.ts)https://api.hsforms.com/submissions/v3/integration/submitTest hook: where the HubSpot form connector posts. Leave unset in production.
SMTP_URL, SMTP_FROMSee above; read in forms/connectors/email.ts and notifications/outbox.ts.
KITS_DIRunsetA folder of kits (<dir>/<name>/kit.json) offered under Kits instead of the bundled packages/kits.
REDIS_URLunsetredis://host:6379/0 (or rediss://) moves the page cache to Redis so several app instances share one cache and evict each other's entries; each instance keeps a small local LRU in front. The server pings Redis at startup and refuses to start when it does not answer within 3 seconds. Unset: the in-process cache. See Scaling out.
REDIS_PREFIXspillway:Key prefix in Redis, so several sites can share one server.
SEARCH_URL, SEARCH_API_KEYunsethttp://host:7700 and an API key switch site search to Meilisearch (one index per site, typo tolerance, facets by type and term). Unset: SQLite FTS5 or Postgres full-text search. Run spillway search reindex (or POST /api/search/reindex) after setting it.
BACKUP_TARGETunsetWhere backups go: s3://bucket/prefix (any S3-compatible store: AWS S3, Cloudflare R2, Tigris, MinIO) or a directory (a mounted volume, a second disk). The default target of spillway backup and the target of scheduled runs and Run now. Unset: no scheduled backups. See Backups.
BACKUP_SCHEDULEunsetWhen the ticker backs up, in UTC: hourly (hourly@MM for another minute), daily (03:00; daily@HH or daily@HH:MM) or weekly (Sunday 03:00; weekly@HH, weekly@mon, weekly@mon 04:30). Needs BACKUP_TARGET. Unset: backups run only by hand.
BACKUP_RETENTION_DAYS30After each run, runs older than this are deleted from the target (the newest is always kept), then the media files no remaining run names. 0 keeps everything.
BACKUP_ENCRYPTION_KEYunsetEncrypts every backup member with AES-256-GCM (the database, the instance key files, modules, form uploads): 64 hex characters (openssl rand -hex 32) or a passphrase. Strongly recommended: backups carry the key files that decrypt every stored credential, so without it they sit in clear on the target (the run is flagged unencrypted_secrets and /status.json reports backup_unencrypted). Keep it outside the instance and outside the backup: without it nothing can be restored. Media files are stored in clear (they are public once published).
BACKUP_S3_ENDPOINT, BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEYAWS S3, region autoThe S3-compatible endpoint and credentials for an s3:// target. R2: https://<account>.r2.cloudflarestorage.com; Tigris: https://fly.storage.tigris.dev; AWS: leave the endpoint unset and set the bucket's region. AWS_ENDPOINT_URL_S3, AWS_ENDPOINT_URL, AWS_REGION, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (what fly storage create sets) are read when these are unset.
STATUS_PAGEonoff (or 0) turns the public status page (/status and /status.json) off; both answer 404.
STATUS_PROBE_URLSunsetComma-separated instance URLs the status probe records every five minutes for a StatusBoard's 90-day uptime bar, in addition to the URLs published StatusBoard blocks name. See Status.
REGISTRY_URL (read in modules/)https://registry.spillwaycms.org/index.jsonThe module registry index. https:// or file://; plain http:// is refused unless REGISTRY_ALLOW_HTTP=1 (development only).
REGISTRY_PUBLIC_KEYunsetBase64 ed25519 public key (the public.txt from spillway registry keygen). When set, a registry index without a valid signature is refused, so a compromised CDN cannot swap tarballs and checksums together. See modules.md.
REGISTRY_ALLOW_HTTPunset1 allows a plain http:// registry URL and tarballs. Never in production.
REGISTRY_MAX_INDEX_AGE_DAYS (read in modules/registry-client.ts)30With REGISTRY_PUBLIC_KEY set, a signed index older than this is refused as registry_stale; 0 turns the check off. See security.md.
DOCS_DIR (read in marketing/seo.ts)docs/reference next to the packagesFolder holding the generated llms-full.txt and openapi.json (written by pnpm docs:build), served at /llms-full.txt (when the site's llms.txt is on) and /openapi.json. A missing file answers 404.
REGISTRY_HOSTunset1 runs this instance as a package registry (/registry/v1/*, /api/registry/*): publishing, the signed index, review. See registry.md.
REGISTRY_SIGNING_KEYunsetRegistry hosts only: path of the ed25519 private key PEM (spillway registry keygen) that signs /registry/v1/index.json.
REGISTRY_STORAGE<DATA_DIR>/registryRegistry hosts only: where published tarballs are stored.
REGISTRY_FOUR_EYESunsetRegistry hosts only: 1 makes approving a trusted module version refuse the person who published it, the package's publisher and its namespace's maintainers (403 four_eyes). See registry.md.
SPILLWAY_REGISTRY, SPILLWAY_REGISTRY_TOKEN (read in cli.ts)unsetDefaults for spillway publish --registry and --token, and for spillway kit install --registry.
ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY, OPENAI_COMPATIBLE_API_KEYunsetFallback AI provider keys when none is stored under Site settings → AI. See api.md.
NODE_ENVunsettest lifts the outbound private-network guard and development allows a local http://127.0.0.1 AI base URL; leave unset or production in production.

Enterprise sign-in (OIDC and SAML with Okta, Entra ID, Google Workspace, Shibboleth/InCommon, Login.gov) needs no environment variable: providers are configured under Site settings → Sign-in, and the OIDC client secrets are encrypted with a key generated into <DATA_DIR>/sso-secret (mode 0600, keep it with your backups). BASE_URL must be set so the redirect, ACS and metadata URLs handed to the identity provider match the public address. See sso.md.

Fleet CLI

spillway-fleet (packages/fleet) reads its own variables; the full description of each is in fleet.md.

VariableDefaultWhat it does
FLEET_HOSTflyDefault --host: fly, docker, kubernetes, railway, render.
FLEET_STATE~/.spillway/fleet.jsonThe fleet registry file.
FLEET_IMAGEghcr.io/kwall-llc/spillway-cmsBase image; --tag is appended.
FLEET_WEBHOOK_URL, FLEET_WEBHOOK_SECRETunsetLifecycle event webhook and its HMAC signing secret.
FLEET_TOKENunsetBearer token for spillway-fleet serve (or --token).
PORT, HOST4000, 0.0.0.0Where spillway-fleet serve listens.
FLY_API_TOKEN, FLY_ORGrequired for flyFly.io API token and organization slug.
FLY_REGIONamsRegion for the volume and the machine.
FLY_MPGunset1 asks for Fly Managed Postgres (answers not_supported today).
FLY_SECRETS_MODEgraphqlgraphql sets app secrets; env writes them into the machine config (debugging only).
FLY_MIN_MACHINES0min_machines_running for the site's machine.
FLY_API_BASE, FLY_GRAPHQL_URLthe public endpointsOverrides for tests.
DOCKER_HOSTunix:///var/run/docker.sockDocker Engine endpoint for the docker host.
FLEET_DOCKER_PORTDocker picks oneHost port to publish 3000 on.
FLEET_DOCKER_PUBLIC_HOST127.0.0.1Host name used in the site URL.
BASE_URLunsetThe docker host's fallback site origin when no domain is given.
FLEET_K8S_NAMESPACEspillwayNamespace for the rendered manifests.
FLEET_K8S_DIR~/.spillway/k8sWhere manifests are written, one folder per site.
FLEET_K8S_INGRESS_CLASS, FLEET_K8S_STORAGE_CLASS, FLEET_K8S_CLUSTER_ISSUERunsetIngress class, PVC storage class, cert-manager ClusterIssuer.

Postgres

SQLite is the default and is right for one instance on one disk. Postgres is for running more than one instance, for a managed database with its own backups, or when the site should not live on a volume. Everything behaves the same on both: the same migrations, the same API, the same site search (FTS5 on SQLite, tsvector with a GIN index on Postgres). Postgres 14 or newer; 17 is what CI and the compose profile use.

Switch by setting DATABASE_URL to a connection URL:

DATABASE_URL=postgres://spillway:secret@db.internal:5432/spillway
# TLS as the driver understands it: ?sslmode=require, ?ssl=true, or sslrootcert=... on the URL

DATA_DIR is still needed: uploads and the generated secrets live there. Migrations run on boot as before, or ahead of time with spillway migrate:

DATABASE_URL=postgres://... node packages/core/dist/cli.js migrate

Docker Compose with Postgres

docker-compose.yml has an optional postgres service (postgres:17, with a health check and its own volume) behind the postgres profile:

DATABASE_URL=postgres://spillway:spillway@postgres:5432/spillway docker compose --profile postgres up -d

Change POSTGRES_PASSWORD (and the URL) before exposing anything. Without the profile and without DATABASE_URL, compose runs SQLite in the spillway-data volume as before.

Fly Postgres

fly postgres create --name my-spillway-db
fly postgres attach my-spillway-db --app my-spillway   # sets DATABASE_URL as a secret
fly deploy

fly postgres attach sets DATABASE_URL to a postgres:// URL on the private network, which is all Spillway needs. Keep the /data volume for uploads, or move media to object storage when that lands. With Postgres you can run more than one machine behind Fly's proxy; the in-process page cache is per machine, so each one warms separately.

Moving a site from SQLite to Postgres

spillway export and spillway import carry the site settings, content types, entities, revisions, users and media metadata between databases:

# 1. From the SQLite install (DATABASE_URL unset or pointing at the file)
node packages/core/dist/cli.js export spillway-export.json

# 2. Against the new database
DATABASE_URL=postgres://... node packages/core/dist/cli.js migrate
DATABASE_URL=postgres://... node packages/core/dist/cli.js import spillway-export.json

# 3. Rebuild the search index once the server is up (POST /api/search/reindex as an admin), then
#    start the server with DATABASE_URL set.

Imported users get a random unusable password (the export never carries hashes), so reset them with spillway create-user or in the admin. Copy DATA_DIR/media across unchanged; the export holds media metadata, not the files. Redirects, collections, forms and submissions, menus, vocabularies and the other tables are not part of the export; recreate them in the admin, or carry the structure with Kits → Export structure (kits.md).

Backups on Postgres

spillway backup --to runs pg_dump --format=custom when the PostgreSQL client tools are on PATH (the official image does not include them; install the same major version as the server, for example apt-get install postgresql-client-17) and stores the dump with the key files, modules, form uploads and media like a SQLite run. Without pg_dump the run fails, and the error prints the exact command to run where the tools are (the password is left out: pass it as PGPASSWORD). A Postgres run is restored with pg_restore: spillway restore downloads and verifies it, saves the dump under DATA_DIR/backups/, puts the key files, modules and (with --media) files back, and prints the command. Managed snapshots hold the database only: the key files in DATA_DIR still need a backup run (or a copy of their own) for the stored credentials to decrypt. Managed Postgres (Fly, RDS, Neon) also keeps its own snapshots. spillway export works the same on both dialects. See Backups.

Scaling out: Redis and Meilisearch

One Spillway process serves a site comfortably; for several instances behind a load balancer, or for a bigger search corpus, two optional services plug in:

  • Redis page cache (REDIS_URL). Rendered pages live in Redis under REDIS_PREFIX with their tags (spillway:page:<key>, spillway:tag:<tag>); a publish on any instance deletes the tagged entries in one pipeline and publishes the tags on spillway:invalidate, and every instance evicts them from its local LRU (200 entries in front of Redis). Entries expire after 24 hours at most, listings and search results after a minute. Point every instance at the same Redis and the same DATABASE_URL, set SESSION_SECRET explicitly, and set SPILLWAY_TICKER=0 on all but one of them. Memory: maxmemory with allkeys-lru keeps Redis bounded; the compose file's redis profile does that. Startup checks the connection: when the server at REDIS_URL does not answer a ping within 3 seconds (wrong host or port, a closed port, a TLS server reached over redis://, bad credentials), the process exits with Redis at redis://host:6379/0 did not answer the startup ping within 3000 ms (...), the URL printed without its credentials; fix REDIS_URL or unset it to run on the in-process cache. Once started, a Redis that goes away does not stall requests: the client keeps no offline queue, so a cache read or write fails at once (logged as redis cache error) and the page is rendered as a cache miss until the server is back, when the client reconnects on its own.
  • Meilisearch (SEARCH_URL, SEARCH_API_KEY). Every publish sends the entity's document (title, body text, term labels, locale, type, status) to the index spillway_<siteId>; queries get typo tolerance, highlights and facet counts by type and term (facets in GET /api/search). Index writes are queued so a slow or absent server never delays a publish (failures are logged); run spillway search reindex after enabling it or after an outage. The SQL index stays in sync as well, so turning Meilisearch off again loses nothing.

Both are optional for the Docker Compose file: docker compose --profile redis --profile search up -d starts them.

Reverse proxy

Put Spillway behind a TLS-terminating proxy in production and set BASE_URL to the public https:// origin (that also turns on Strict-Transport-Security). With TRUSTED_PROXIES set (below), X-Forwarded-Proto: https from the proxy is honored too, so the Secure and __Host- cookie flags and HSTS follow the real scheme even when BASE_URL is unset or several hostnames serve the site; from an untrusted peer the header is ignored. Spillway uses the visitor's address for login and form rate limiting and for the derivative cache, and it only believes X-Forwarded-For when the request came from a proxy you named in TRUSTED_PROXIES; otherwise every visitor behind the proxy would share one bucket, and a visitor could forge the header. Set it to the proxy's address or range:

  • Caddy or nginx on the same machine: TRUSTED_PROXIES=loopback (127.0.0.0/8, ::1/128).
  • Docker behind Caddy, nginx or Traefik on the Docker network: TRUSTED_PROXIES=172.16.0.0/12 (Docker's default bridge and compose networks), or the exact subnet from docker network inspect; private covers every RFC 1918 range when you do not want to look it up.
  • Fly.io: TRUSTED_PROXIES=fly; Fly's edge sets Fly-Client-IP, which is then used.
  • Kubernetes, Render, Railway: the ingress or platform proxy's pod/network range, or * when the port is reachable only through that proxy.

Spillway keeps idle keep-alive connections open for 65 seconds, longer than the 60 second idle timeout most proxies and load balancers use, so the proxy never reuses a connection the server is closing at the same moment. Responses are gzip-compressed when the client sends Accept-Encoding: gzip (HTML, JSON, XML, text and scripts from 1 KB up; images are left alone), and every public page carries a weak ETag; a proxy that compresses on its own can send Accept-Encoding: identity to the origin.

With a trusted peer, the client is the rightmost X-Forwarded-For hop that is not itself a trusted proxy, so a hop the client added itself is ignored.

Caddy (Caddyfile), with automatic HTTPS:

www.example.org {
    reverse_proxy localhost:3000
}

nginx:

server {
    listen 443 ssl http2;
    server_name www.example.org;

    ssl_certificate     /etc/letsencrypt/live/www.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/www.example.org/privkey.pem;

    client_max_body_size 65m;   # media uploads are capped at 25 MB, module archives at 64 MB; Spillway enforces both itself

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}

Spillway serves pages with Cache-Control and Cache-Tag headers, so a CDN or cache in front of the proxy can store published pages and purge by tag. See architecture.md.

Fly.io

fly launch --no-deploy        # answer the prompts; pick a region
fly volumes create spillway_data --size 1
fly secrets set SESSION_SECRET="$(openssl rand -base64 48)"
fly deploy

fly.toml:

app = "my-spillway"
primary_region = "ams"

[build]
  image = "ghcr.io/kwall-llc/spillway-cms"

[env]
  PORT = "3000"
  DATA_DIR = "/data"
  BASE_URL = "https://my-spillway.fly.dev"

[[mounts]]
  source = "spillway_data"
  destination = "/data"

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 1

  [[http_service.checks]]
    interval = "30s"
    timeout = "5s"
    method = "GET"
    path = "/healthz"

Keep one machine with SQLite: it lives on the volume and the page cache is in-process. Set min_machines_running = 1 so the first request after idle is not slow. With Fly Postgres you can scale out.

To create Fly sites without flyctl, from one command or one HTTP call (app, volume, secrets, machine, setup wizard, template, custom domain), see fleet.md.

Railway, Render, Coolify

All three can run the image directly. Give it a persistent disk mounted at /data, expose port 3000 and set BASE_URL.

  • Railway: railway init && railway up with a Dockerfile that is just FROM ghcr.io/kwall-llc/spillway-cms, then add a volume mounted at /data in the service settings and set BASE_URL to the generated domain.
  • Render: create a Web Service from the image ghcr.io/kwall-llc/spillway-cms, add a disk at /data, health check path /healthz, env BASE_URL. Disks require a paid instance.
  • Coolify: New resource, Docker Image, ghcr.io/kwall-llc/spillway-cms, port 3000, a persistent storage at /data, env BASE_URL.

spillway-fleet render-config --host railway|render writes the railway.json / render.yaml for these steps, and the Docker adapter in fleet.md drives a Coolify-style host through the Docker Engine API.

Backups

With SQLite, everything is under DATA_DIR:

data/
  spillway.db        SQLite database (plus spillway.db-wal and spillway.db-shm while running)
  media/<id>/<file>  uploaded files and their cached derivatives (_v/)
  session-secret     generated session key (mode 0600)
  sso-secret         key for the stored SSO client secrets (sso.md; mode 0600)
  ai-secret          key for the stored AI, DAM, TMS and CDN credentials (mode 0600)
  form-uploads/      files visitors attached to forms
  imports/           raw pages fetched by the domain importer
  modules/<name>/    installed modules
  registry/          published tarballs, on a registry host (REGISTRY_STORAGE)
  restart-stamp      when a module change last restarted the server
  backups/           what `spillway backup` (without --to) writes

What a backup contains

A backup run (spillway backup --to, the schedule or Run now) carries everything a restored instance needs to behave like the one that was backed up:

In DATA_DIRIn the backupWhy
spillway.dbyes, as a consistent dump (SQLite's online backup, or pg_dump on Postgres)the content, users, settings, sessions, module state
session-secret, sso-secret, ai-secretyes, in keys.jsonthe key files: without them every stored SSO client secret and AI, DAM, TMS and CDN credential is unreadable after a restore, and every session ends
the REGISTRY_SIGNING_KEY PEMyes, when it lies inside DATA_DIRa registry host keeps signing its index with the same key
modules/yes, every file (modules.json)installed modules load again; whether each is enabled and its settings are in the database
media/yes, unless --no-media (media.json)the media library; cached derivatives are rebuilt on demand
form-uploads/yes, unless --no-media (form-uploads.json)form submission attachments download again
registry/yes on a registry host, unless --no-media (registry.json)published tarballs; a REGISTRY_STORAGE outside DATA_DIR is yours to back up
imports/nothe importer's scratch copies of fetched pages and assets; what was imported is in the database and the media library
restart-stamp, backups/noa rate limit for module restarts, and local copies from spillway backup --out

Secrets the instance reads from its environment are not in DATA_DIR and so not in a backup: keep SESSION_SECRET (when you set one; the fleet does, and keeps a sealed copy with BACKUP_ENCRYPTION_KEY that spillway-fleet secrets recovery <id> prints), SMTP_URL, provider keys you set as variables, the BACKUP_S3_* credentials and BACKUP_ENCRYPTION_KEY itself in your secret store. The manifest notes when SESSION_SECRET came from the environment, and the restore reminds you to set it.

Set BACKUP_ENCRYPTION_KEY. Because the key files are inside every backup, anyone who can read the backup target can decrypt the stored credentials. With the key set, every member of a run is encrypted with AES-256-GCM (only media files stay in clear, as they are public once published). Without it the key files are still included, since a backup that cannot be restored is worse, but the run is marked with the unencrypted_secrets warning, the Backups card under Site settings → System shows how to fix it, /status.json reports backup_unencrypted, and the server logs a warning at boot when the target is s3://. Keep the key outside the instance (a password manager, your platform's secret store): it is the one thing a backup cannot carry.

Scheduled backups to object storage

Set a target and a schedule, and the server backs itself up:

BACKUP_TARGET=s3://my-backups/spillway/www     # or a directory: /mnt/backups/spillway
BACKUP_SCHEDULE=daily@03                       # hourly, daily[@HH[:MM]], weekly[@HH | @DAY [HH[:MM]]], in UTC
BACKUP_RETENTION_DAYS=30                       # 0 keeps every run
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32)  # recommended (backups carry the key files); keep a copy outside the instance
BACKUP_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com   # R2; Tigris: https://fly.storage.tigris.dev; AWS: unset
BACKUP_S3_ACCESS_KEY_ID=...
BACKUP_S3_SECRET_ACCESS_KEY=...

Each run is a folder named by its UTC time under the target, plus shared folders of files stored once by content hash:

s3://my-backups/spillway/www/
  20261106-030000/spillway.db        the database (spillway.db.enc when encrypted; spillway.pgdump on Postgres)
  20261106-030000/keys.json          the key files (keys.json.enc when encrypted)
  20261106-030000/modules.json       every file of every installed module: path, size, mode and sha256 (.enc when encrypted)
  20261106-030000/form-uploads.json  every form upload, the same way (.enc when encrypted)
  20261106-030000/registry.json      a registry host's tarballs, the same way (.enc when encrypted)
  20261106-030000/blobs.json         the shared files this run names (content hashes only), for pruning
  20261106-030000/media.json         every media item: id, file name, type, size and sha256, or its DAM reference
  20261106-030000/backup.json        written last: version, applied migrations, a checksum of every member, encryption, warnings
  media/<sha256>                     each media file once, by content hash
  modules/<sha256>[.<key id>.enc]    each module file once (form-uploads/ and registry/ likewise); encrypted ones carry the key's id

Backups are incremental for media, form uploads, modules and registry tarballs: a run uploads only the files whose content hash the target does not hold yet (with a key set, encrypted files are named by the key's id too, so changing the key uploads them again), and items that live in a DAM are referenced, not copied. A run without backup.json is incomplete and is never restored. The ticker starts a run once per slot (and once at the first tick of a fresh instance, so a backup exists before the first slot), one at a time, in the background; it records every run in the database, then deletes runs older than BACKUP_RETENTION_DAYS (always keeping the newest) and the shared files no remaining run names. A failed run sends the Backup failed notification to any rule for it (Site settings → Notifications). Site settings → System lists the runs and has a Run now button (POST /api/system/backups/run, see api.md, System). With SPILLWAY_TICKER=0, run spillway backup from cron instead.

The S3 client is built in (SigV4 on node:crypto, path-style requests, no SDK) and goes through the same outbound guard as webhooks, so an endpoint on a private network needs SPILLWAY_ALLOW_PRIVATE_URLS=1 (a MinIO next to the server). The credentials need read, write, list and delete on the prefix.

From the terminal:

pnpm spillway backup --to s3://my-backups/spillway/www          # one run now (BACKUP_TARGET is the default)
pnpm spillway backup --to /mnt/backups/spillway --no-media      # without media, form uploads and registry tarballs (key files and modules stay in)

Restoring

spillway restore takes a run folder, or a target to restore its newest run. Stop the server first: the restore replaces the database file directly.

pnpm spillway restore s3://my-backups/spillway/www --dry-run              # newest run: download, verify, report what would change
pnpm spillway restore s3://my-backups/spillway/www/20261106-030000 --media
pnpm spillway restore /mnt/backups/spillway --data-dir /srv/spillway-copy --media   # into another data directory

It downloads the run, checks every member against the checksums in backup.json (before and after decrypting with BACKUP_ENCRYPTION_KEY) before it writes anything, refuses a backup taken by a newer Spillway (upgrade first), refuses a destination database that was migrated further than the backup unless you pass --force, verifies the SQLite file with quick_check, and keeps the replaced database next to the new one as spillway.db.before-restore-<stamp>. It then puts the key files back with mode 0600 and the installed modules back under modules/ (their enabled state and settings came back with the database), and with --media writes every media file, form upload and registry tarball whose content differs from what is on disk. Start the server afterwards: stored credentials decrypt, existing sessions stay signed in, form uploads download and modules load. The server applies any migrations newer than the backup. --dry-run does every download and check, lists every file it would write (the database, each key file, each module file, each media file and form upload) and writes nothing.

Key files are never replaced silently. If the data directory already holds a key file that differs from the backup's (an instance that was started once before the restore generates its own), the restore stops before writing anything and names the files: keeping them would leave the backup's credentials unreadable, and replacing them does the same to anything saved with the current keys. Pass --force to replace them; the current files are kept as <file>.before-restore-<stamp>. Restore into a fresh directory, or one whose instance you are replacing, and start the server only after the restore. A Postgres run restores the key files, modules and files the same way; the database itself goes back with the printed pg_restore command.

The restore drill does all of this end to end against a throwaway site (node scripts/restore-drill.mjs after pnpm build): it stores an SSO client secret and an AI key, submits a form with a file, installs the example sandboxed module, backs up, restores into a fresh directory, boots it and checks that the credentials decrypt, the upload downloads with the same bytes, the module runs and a session from before the backup is still signed in. CI runs it on every change.

Manual copies

The database runs in WAL mode, so do not copy spillway.db alone while the server is running; recent writes are in the -wal file. Either:

  • run spillway backup (with BACKUP_TARGET set, a bare spillway backup runs a backup to the target instead; --out keeps the local copy), which uses SQLite's online backup to write a consistent single file into DATA_DIR/backups/spillway-<timestamp>.db and verifies it with PRAGMA quick_check before reporting success (--out FILE chooses another path; it never overwrites):
    docker exec spillway node packages/core/dist/cli.js backup          # /data/backups/spillway-20261105-140309.db
    pnpm spillway backup                                                 # from source: data/backups/...
    pnpm spillway backup --out /mnt/nightly/spillway.db
    

    then copy that file, the media/, form-uploads/ and modules/ folders and the key files (session-secret, sso-secret, ai-secret) away (the backup folder is not pruned; delete old files or keep it on a mounted volume). The same thing without Spillway is sqlite3 data/spillway.db ".backup data/backup.db" (the sqlite3 command ships with macOS and most Linux distributions). spillway backup refuses Postgres databases and points at pg_dump (next section), or

  • run spillway export <file>, which writes a JSON file with the site settings, types, entities, revisions, users (without password hashes) and media metadata:
    docker exec spillway node packages/core/dist/cli.js export /data/export.json
    docker cp spillway:/data/export.json ./spillway-export.json
    

    From source: pnpm spillway export spillway-export.json.

    Back up media/ separately; the export contains metadata, not the files. spillway import <file> loads an export into a database, upserting by id; imported users get a random unusable password, so reset them with spillway create-user or in the admin.

If the server is stopped, copying the whole data/ directory is a complete backup.

Status

Every instance serves a public status page at /status (HTML) and /status.json (for monitors), with Cache-Control: no-store and no sign-in:

curl -s https://www.example.org/status.json
# {"status":"up","version":"0.1.0","uptimeSeconds":86400,"database":"sqlite","cache":"memory","search":"sqlite",
#  "ticker":{"enabled":true,"lastTickAt":"...","stale":false},"backup":{"configured":true,"lastRunAt":"...","lastStatus":"ok","failed":false},
#  "redis":{"ok":null},"degraded":[],"checkedAt":"..."}

status is degraded when any flag is set: ticker_stale (the in-process ticker has not ticked for five minutes), backup_failed (backups are configured and the last finished run failed), backup_unencrypted (backups are configured, or the last run holds the key files, without BACKUP_ENCRYPTION_KEY; see What a backup contains) or redis_down (the page cache is on Redis and Redis does not answer within a second). The page shows no hosts, URLs, keys or error text. Point an uptime monitor at /status.json and alert on a non-200 answer or "status":"degraded". STATUS_PAGE=off turns both endpoints off. If a site publishes its own page at /status, visitors get that page and /status.json stays the instance's.

The project site lists several instances on one page with the StatusBoard block (blocks.md): the server fetches each instance's /status.json (3 second timeout, through the outbound guard, cached for a minute), and a ticker step probes STATUS_PROBE_URLS plus every URL a published StatusBoard names every five minutes into the status_check table (kept 90 days) for the uptime bar.

Upgrading

  1. Take a backup: spillway backup --to <target> (the database, key files, modules, form uploads and media, on SQLite and Postgres), or spillway backup for a local copy of the SQLite file (copy media/, form-uploads/, modules/ and the key files too).
  2. Get the new version and restart:
    docker compose pull && docker compose up -d          # published image
    git pull && docker compose up -d --build             # image built from the checkout
    git pull && pnpm install && pnpm build && pnpm start # from source (stop the old server first)
    

    From source, run pnpm install even when the lockfile looks unchanged: a Node major upgrade needs better-sqlite3 rebuilt (pnpm rebuild better-sqlite3 does it on its own), and pnpm preflight tells you when that is the case.

  3. Migrations run on boot, before the server starts listening. Each migration runs once, in its own transaction, and is recorded in the _migrations table. The log prints applied migrations with their names (the names are not in numeric order; that is the dependency order and is expected). To run them without starting the server: pnpm spillway migrate (node packages/core/dist/cli.js migrate); spillway migrate --status lists what is applied and pending without changing anything.
  4. Going back: a build refuses to start on a database that a newer build has migrated ("This database was migrated by a newer version of Spillway"), because the old code was never tested against the new schema. To return to the previous version, stop, restore the backup from step 1 (spillway restore <location> --media with the new build, or copy the file and media/ back), start the old version. Which upgrades are supported, and for how long a release gets fixes, is in docs/dev/releases.md.

The spillway command

@spillway-cms/core declares a spillway binary (packages/core/dist/cli.js), but nothing puts it on PATH: from a source checkout call it as pnpm spillway <command> (a root script; it also reads .env) or node packages/core/dist/cli.js <command>, and inside the Docker image as node packages/core/dist/cli.js <command> from /app. pnpm exec spillway does not work, because a package's own binary is not linked into its node_modules/.bin. Every form reads the same environment variables as the server, so set DATA_DIR (or DATABASE_URL) the same way you start it. Where a guide shows a bare spillway ..., read it as one of these. spillway help prints the same list; the generated reference/cli.md has it too.

spillway start                                  run the server
spillway migrate [--status]                     apply pending migrations and exit; --status lists applied and pending ones (exit 1 when the database was migrated by a newer Spillway)
spillway backup [--out FILE]                    consistent copy of the SQLite database into DATA_DIR/backups/ (copy media/ separately)
spillway backup --to <s3://bucket/prefix | DIR> [--no-media]   a backup run: database (pg_dump on Postgres), media manifest and new media files (Backups)
spillway restore <location> [--data-dir DIR] [--media] [--dry-run] [--force]   restore a run: verify, decrypt, schema check, database and media (Restoring)
spillway create-user --email E --password P --name N [--role admin] [--site-name NAME]
spillway export <file.json>                     write site, types, entities, revisions, users, media metadata
spillway import <file.json>                     load an export file (upserts by id)
spillway import <url> [--max-pages N] [--rate-limit ms] [--no-robots] [--no-sitemap] [--include G] [--exclude G] [--site KEY]
                [--keep-offsite-redirects] [--classify] [--convert] [--theme] [--land] [--json]   crawl a website and land it as drafts (importer.md)
spillway run-scheduled                          one pass of the scheduler, scheduled workstream merges and the notification outbox (for cron; set SPILLWAY_TICKER=0 on the server)
spillway prune-revisions [--keep N]             trim revision history to the site's keepRevisions setting (or N)
spillway kit list                               list the bundled kits (higher_ed, city, nonprofit, portal, docs) and whether they are installed
spillway kit install <key> [--sample]           install a kit's types, vocabularies, collections, forms and pages; --sample adds its sample content
spillway kit install <name>[@version] --registry <url> [--sample]   install a kit or theme from a package registry (sha256 verified against its signed index)
spillway sync-occurrences                       re-expand every daterange field into the occurrence table (run monthly from cron)
spillway site list                              list the sites of this instance
spillway site create --key K --name N [--domain host] [--locale en]   create a site
spillway search reindex                         rebuild the search index (SQL, and Meilisearch when SEARCH_URL is set)
spillway registry keygen [--out DIR]            ed25519 key pair for signing a module registry index
spillway registry sign <index.json> --key <private.pem> [--out FILE]   sign a registry index
spillway registry serve                         run this instance as a package registry (REGISTRY_HOST=1 spillway start)
spillway publish [dir] --registry <url> --token <spw_...>   pack a module, kit or theme folder and publish it

create-user is the way back in if you lose the only admin password:

docker exec spillway node packages/core/dist/cli.js create-user --email you@example.org --password 'new-password' --name 'You'
pnpm spillway create-user --email you@example.org --password 'new-password' --name 'You'   # from source

The email must be new; to reset an existing user's password, create a second admin this way, sign in as it and change the password under Users.

Migrations are forward-only, and an older build refuses a database a newer one has migrated. To roll back, stop the new version and restore the backup.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?