Hosting & deployment
A Minamo site has no database and no required external service. What it needs is Node, the real git binary, and a permanent disk. You deploy a site once; after that you move changes with pull and push.
Requirements
- Node 22.16 or later, enforced at boot — a site refuses to start on anything older. The search index’s full-text engine (
node:sqlitewith FTS5) isn’t available before this patch. - The
gitbinary onPATH, and the site root must be a real git repository. Content, drafts, and every publish are real git operations; there is no fallback. - A permanent disk for the whole site (
content/,theme/,media/,vhost/data/), not just media. Publishes are commits made by the running server itself — without a permanent disk, every one since the last deploy is lost on restart.
Choosing a host
Any host works if it gives you an always-on Node server (22.16 or newer), a permanent disk, and git.
| Works | Does not work |
|---|---|
| Railway, Fly.io, Render (paid plans), Northflank | Vercel, Netlify, Cloudflare Pages |
| Your own server: Hetzner, DigitalOcean, Linode, Vultr, BinaryLane, AWS Lightsail | Heroku, DigitalOcean App Platform |
| A server running Coolify, Dokku or CapRover | Classic shared hosting |
The hosts that don’t work run code in short bursts with no permanent disk, so editors’ changes would be lost.
Putting a site live for the first time
The first deploy copies everything in your site folder onto the host: the CMS, your theme, your content and your images. Every host needs a permanent disk mounted at /site.
| Host | First deploy |
|---|---|
| Railway | railway up --no-gitignore from the site folder |
| Fly.io | fly deploy --dockerfile vhost/Dockerfile from the site folder |
| Your own server | docker compose up -d --build on the server |
| Render, Coolify, and hosts that deploy from GitHub | Push the site to GitHub |
You only do this once per site. Deploying again later never changes the live site’s content or theme — those belong to the live site now, where editors are changing them. Use pull and push from here on.
How the Docker image works
create-site scaffolds a working Dockerfile and docker-entrypoint.sh in vhost/. The image bakes the site into /seed at build time; on first boot it copies that into the empty /site volume, and runs the server against /site from then on.
docker build -f vhost/Dockerfile -t my-site .
docker run -d -p 3000:3000 -v my-site-data:/site my-site
Every later boot refreshes only the installed CMS from the image (vhost/package.json, its lock file and node_modules), so redeploying with a newer CMS upgrades a running site without touching its content. Content, drafts, media, the theme, and vhost/site.config.json (its tokens) belong to the live site and are left alone. Without the volume, the container’s writable layer is thrown away on every redeploy, and every publish with it.
Health-check against GET /v1/capabilities — a real, always-available endpoint that doubles as a liveness check.
Without Docker
On your own machine or server, install Node 22.16+ and git, then:
cd vhost
npm install
npm start
Run it under a process supervisor (systemd, pm2) so it restarts on crash or reboot, and put a reverse proxy (nginx, Caddy) in front for TLS — set trustProxy: true once you do.
site.config.json reference
All fields are optional. A missing file, or a missing field, falls back to the default shown — the safe, default-secure state (a zero-token site fails closed on every write route), not an error. A malformed file is a hard startup failure.
| Field | Default | Notes |
|---|---|---|
port | 3000 | Must match the scaffolded Dockerfile’s EXPOSEd port if built unmodified. Overridden by the PORT environment variable if set. |
tokens | [] | Array of { hash, scopes }. hash is a sha256 hex digest of the real token, never the raw token. Rotating a token requires a restart. |
rateLimit | { max: 60, windowMs: 60000 } | Per-IP. |
trustProxy | false | Set true when a reverse proxy or platform edge sits in front, so client IPs are read correctly. |
ipAllowlist | [] | Empty means no restriction, not “nothing allowed”. |
checkpointIntervalMs | 1800000 (30 min) | How often open drafts are auto-committed as a safety checkpoint. |
media.maxUploadBytes | 10485760 (10MB) | |
adminBaseUrl | unset | An absolute http(s) URL for whichever admin manages this site. When set, GET /admin redirects there with ?site=<host> appended, so visiting yoursite.com/admin jumps straight to that site in the admin. When unset, /admin isn’t reserved at all. |
Backing up media
media/ is deliberately not git-tracked — it’s binary, uploaded content, not source. Back it up with the rest of the volume (a scheduled snapshot, rsync, a tarball). npm run pull also downloads every image the live site has into a local copy.
Local dev tunnel
npm run tunnel exposes a locally running site through a public HTTPS URL, printed on boot — useful for pointing a hosted admin at a site you’re developing, without deploying it. The tunnel closes on shutdown, and a failed tunnel never stops the site itself from starting.
Anyone with the URL and a valid access token can reach it. Don’t leave it running unattended, and don’t use it as a substitute for real hosting.