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 (
.nvmrcpins 22; 24 works too) and pnpm 10. Node 22 ships corepack, socorepack enableinstalls the pnpm pinned inpackage.json;npm install -g pnpm@10is the alternative. The two native modules,better-sqlite3andsharp, download prebuilt binaries duringpnpm installfor 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 enablemakes thepnpmcommand available and, on first use, downloads the exact version frompackageManagerinpackage.json. Skip it ifpnpm --versionalready prints 10.x.pnpm preflight(scripts/doctor.mjs; notpnpm 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 installinstalls every workspace package. The two native modules run their install scripts (package.jsonallowlists them underpnpm.onlyBuiltDependencies, so pnpm 10 does not print its "Ignored build scripts" warning for them):better-sqlite3downloads a prebuilt binary from its GitHub release,sharpinstalls the prebuilt@img/sharp-*package for your platform. Both are plain downloads; see Native modules if one fails.pnpm buildbuildsshared,blocks,admin(Vite, intopackages/admin/dist),core(tsc, intopackages/core/dist) and thefleet,sdkanddocs-toolspackages. The one warning it prints, "Some chunks are larger than 1200 kB", is Vite commenting on the admin bundle size and is expected.pnpm startrunsscripts/start.mjs, which loads.envwhen there is one and then startspackages/core/dist/server.js(running that file withnodedirectly is the same without the.envstep). The server applies the migrations, serves the admin frompackages/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": falseuntil 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/admingoes 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 andPOST /api/setuprefuses 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 yourPATHasbrewtells you (echo 'export PATH="$(brew --prefix node@22)/bin:$PATH"' >> ~/.zshrc, then open a new terminal). It includes corepack, socorepack enablethen works. - nvm:
nvm install 22 && nvm use 22; the checkout's.nvmrcletsnvm usepick 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-sqlite3runsprebuild-installduringpnpm install, which downloadsbetter-sqlite3-v<version>-node-v<abi>-<platform>-<arch>.tar.gzfrom the package's GitHub releases; if that fails it falls back tonode-gyp rebuild, which needs Python 3 and a C++ compiler. Behind a proxy that blocksgithub.com, setHTTPS_PROXY, or install the compiler.sharphas no install script:pnpm installpicks 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.onlyBuiltDependenciesinpackage.json;better-sqlite3andesbuildare listed, so there is nothing to approve. If a future dependency adds one,pnpm installprints "Ignored build scripts" andpnpm approve-buildslets you allow it. pnpm preflightloads both modules and prints the SQLite and libvips versions;pnpm rebuild better-sqlite3rebuilds 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.
| Variable | Default | What it does |
|---|---|---|
PORT | 3000 | Port 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.db | Where 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_MAX | 10 | Postgres only: size of the connection pool per server process. |
SESSION_SECRET | generated | Key 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_URL | unset | Public 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_DIST | packages/admin/dist relative to the core package | Folder holding the built admin SPA. Change it only if you build the admin elsewhere. |
LOG_LEVEL | info | One of debug, info, warn, error, silent. Warnings and errors go to stderr, the rest to stdout. |
HOST | 0.0.0.0 | Interface to bind. Read in server.ts. |
SMTP_URL, SMTP_FROM | unset | Mail 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_TICKER | 1 | The 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) | unset | Which 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) | unset | When 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) | unset | 1 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) | unset | 1 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) | unset | 1 boots without loading any module (same as --safe-mode). |
SPILLWAY_CAPTCHA_VERIFY_URL (read in forms/captcha.ts) | unset | Test 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/submit | Test hook: where the HubSpot form connector posts. Leave unset in production. |
SMTP_URL, SMTP_FROM | See above; read in forms/connectors/email.ts and notifications/outbox.ts. | |
KITS_DIR | unset | A folder of kits (<dir>/<name>/kit.json) offered under Kits instead of the bundled packages/kits. |
REDIS_URL | unset | redis://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_PREFIX | spillway: | Key prefix in Redis, so several sites can share one server. |
SEARCH_URL, SEARCH_API_KEY | unset | http://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_TARGET | unset | Where 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_SCHEDULE | unset | When 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_DAYS | 30 | After 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_KEY | unset | Encrypts 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_KEY | AWS S3, region auto | The 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_PAGE | on | off (or 0) turns the public status page (/status and /status.json) off; both answer 404. |
STATUS_PROBE_URLS | unset | Comma-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.json | The module registry index. https:// or file://; plain http:// is refused unless REGISTRY_ALLOW_HTTP=1 (development only). |
REGISTRY_PUBLIC_KEY | unset | Base64 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_HTTP | unset | 1 allows a plain http:// registry URL and tarballs. Never in production. |
REGISTRY_MAX_INDEX_AGE_DAYS (read in modules/registry-client.ts) | 30 | With 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 packages | Folder 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_HOST | unset | 1 runs this instance as a package registry (/registry/v1/*, /api/registry/*): publishing, the signed index, review. See registry.md. |
REGISTRY_SIGNING_KEY | unset | Registry hosts only: path of the ed25519 private key PEM (spillway registry keygen) that signs /registry/v1/index.json. |
REGISTRY_STORAGE | <DATA_DIR>/registry | Registry hosts only: where published tarballs are stored. |
REGISTRY_FOUR_EYES | unset | Registry 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) | unset | Defaults 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_KEY | unset | Fallback AI provider keys when none is stored under Site settings → AI. See api.md. |
NODE_ENV | unset | test 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.
| Variable | Default | What it does |
|---|---|---|
FLEET_HOST | fly | Default --host: fly, docker, kubernetes, railway, render. |
FLEET_STATE | ~/.spillway/fleet.json | The fleet registry file. |
FLEET_IMAGE | ghcr.io/kwall-llc/spillway-cms | Base image; --tag is appended. |
FLEET_WEBHOOK_URL, FLEET_WEBHOOK_SECRET | unset | Lifecycle event webhook and its HMAC signing secret. |
FLEET_TOKEN | unset | Bearer token for spillway-fleet serve (or --token). |
PORT, HOST | 4000, 0.0.0.0 | Where spillway-fleet serve listens. |
FLY_API_TOKEN, FLY_ORG | required for fly | Fly.io API token and organization slug. |
FLY_REGION | ams | Region for the volume and the machine. |
FLY_MPG | unset | 1 asks for Fly Managed Postgres (answers not_supported today). |
FLY_SECRETS_MODE | graphql | graphql sets app secrets; env writes them into the machine config (debugging only). |
FLY_MIN_MACHINES | 0 | min_machines_running for the site's machine. |
FLY_API_BASE, FLY_GRAPHQL_URL | the public endpoints | Overrides for tests. |
DOCKER_HOST | unix:///var/run/docker.sock | Docker Engine endpoint for the docker host. |
FLEET_DOCKER_PORT | Docker picks one | Host port to publish 3000 on. |
FLEET_DOCKER_PUBLIC_HOST | 127.0.0.1 | Host name used in the site URL. |
BASE_URL | unset | The docker host's fallback site origin when no domain is given. |
FLEET_K8S_NAMESPACE | spillway | Namespace for the rendered manifests. |
FLEET_K8S_DIR | ~/.spillway/k8s | Where manifests are written, one folder per site. |
FLEET_K8S_INGRESS_CLASS, FLEET_K8S_STORAGE_CLASS, FLEET_K8S_CLUSTER_ISSUER | unset | Ingress 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 underREDIS_PREFIXwith their tags (spillway:page:<key>,spillway:tag:<tag>); a publish on any instance deletes the tagged entries in one pipeline and publishes the tags onspillway: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 sameDATABASE_URL, setSESSION_SECRETexplicitly, and setSPILLWAY_TICKER=0on all but one of them. Memory:maxmemorywithallkeys-lrukeeps Redis bounded; the compose file'sredisprofile does that. Startup checks the connection: when the server atREDIS_URLdoes not answer a ping within 3 seconds (wrong host or port, a closed port, a TLS server reached overredis://, bad credentials), the process exits withRedis at redis://host:6379/0 did not answer the startup ping within 3000 ms (...), the URL printed without its credentials; fixREDIS_URLor 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 asredis 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 indexspillway_<siteId>; queries get typo tolerance, highlights and facet counts by type and term (facetsinGET /api/search). Index writes are queued so a slow or absent server never delays a publish (failures are logged); runspillway search reindexafter 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 fromdocker network inspect;privatecovers every RFC 1918 range when you do not want to look it up. - Fly.io:
TRUSTED_PROXIES=fly; Fly's edge setsFly-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 upwith aDockerfilethat is justFROM ghcr.io/kwall-llc/spillway-cms, then add a volume mounted at/datain the service settings and setBASE_URLto 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, envBASE_URL. Disks require a paid instance. - Coolify: New resource, Docker Image,
ghcr.io/kwall-llc/spillway-cms, port 3000, a persistent storage at/data, envBASE_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_DIR | In the backup | Why |
|---|---|---|
spillway.db | yes, 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-secret | yes, in keys.json | the 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 PEM | yes, when it lies inside DATA_DIR | a 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/ | no | the importer's scratch copies of fetched pages and assets; what was imported is in the database and the media library |
restart-stamp, backups/ | no | a 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(withBACKUP_TARGETset, a barespillway backupruns a backup to the target instead;--outkeeps the local copy), which uses SQLite's online backup to write a consistent single file intoDATA_DIR/backups/spillway-<timestamp>.dband verifies it withPRAGMA quick_checkbefore reporting success (--out FILEchooses 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.dbthen copy that file, the
media/,form-uploads/andmodules/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 issqlite3 data/spillway.db ".backup data/backup.db"(thesqlite3command ships with macOS and most Linux distributions).spillway backuprefuses Postgres databases and points atpg_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.jsonFrom 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 withspillway create-useror 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
- Take a backup:
spillway backup --to <target>(the database, key files, modules, form uploads and media, on SQLite and Postgres), orspillway backupfor a local copy of the SQLite file (copymedia/,form-uploads/,modules/and the key files too). - 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 installeven when the lockfile looks unchanged: a Node major upgrade needsbetter-sqlite3rebuilt (pnpm rebuild better-sqlite3does it on its own), andpnpm preflighttells you when that is the case. - Migrations run on boot, before the server starts listening. Each migration runs once, in its own transaction, and is recorded in the
_migrationstable. The log printsapplied migrationswith 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 --statuslists what is applied and pending without changing anything. - 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> --mediawith the new build, or copy the file andmedia/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