Deploying
A Grimoire app in production is one Bun process tree: sigil start runs a small coordinator that listens on your port, and one or more worker processes behind it that render pages. This guide covers what to run, what to put in front of it, and when to add workers. The last tutorial step is a shorter, hands-on version.
Build, then start
bun run build
NODE_ENV=production bun run start -- --no-buildsigil build compiles every route for the browser into public/__grimoire__/. sigil start builds too unless given --no-build; build once, during deployment, and start with --no-build.
Set NODE_ENV=production. With it, unexpected errors never show their details to visitors.
Ports and hosts
The coordinator listens on port and host from sigil.config.ts. Workers listen on 127.0.0.1 only, on the ports after it: with port: 3000 and one worker, the worker takes 3001; with three, 3001 to 3003. Keep those ports free.
Let the environment choose, so the same code runs locally and in a container:
import type { GrimoireConfig } from "@sigil-dev/grimoire";
export default {
port: Number(process.env.PORT ?? 3000),
host: process.env.HOST ?? "localhost",
} satisfies GrimoireConfig;In a container, set HOST=0.0.0.0. On a server with a reverse proxy on the same machine, leave it on localhost so only the proxy can reach the app.
Behind a reverse proxy
Put a proxy that handles TLS in front, such as Caddy, nginx, or your platform's load balancer. With Caddy it's one block, and WebSockets pass through without further setup:
books.example.com {
encode zstd gzip
reverse_proxy localhost:3000
}Things to know behind a proxy:
- The app sees the proxy's
Hostheader, sourl.hostin loads is the public host name. Caddy passes it on by default. - TLS ends at the proxy, so
url.protocolishttp:. Build absolute URLs from a configured origin (an environment variable such asPUBLIC_ORIGIN) rather than fromurlwhen the scheme matters. - The connection comes from the proxy, so the visitor's address is in the
X-Forwarded-Forheader the proxy sets.
Docker
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
ENV NODE_ENV=production HOST=0.0.0.0
EXPOSE 3000
CMD ["bun", "run", "start", "--", "--no-build"]With a .dockerignore for node_modules, .grimoire, public/__grimoire__ and local databases. Keep databases and uploads on a volume, and point the app at them with environment variables. docker stop sends SIGTERM, which shuts the coordinator and its workers down cleanly.
systemd
Without containers, a unit keeps the server running and restarts it if the coordinator itself exits:
[Unit]
Description=Reading list
After=network.target
[Service]
WorkingDirectory=/srv/books
Environment=NODE_ENV=production
ExecStart=/usr/local/bin/bun run start -- --no-build
Restart=on-failure
User=books
[Install]
WantedBy=multi-user.targetWorkers
The coordinator restarts a worker that crashes: after a second, or longer if it keeps crashing on startup. While it's down, requests get a 502.
One worker is the default. For more, pass --scale:
bun run start -- --no-build --scale full=4full workers handle everything, and requests are spread across them in turn. Workers can also specialise:
| Mode | Handles |
|---|---|
full | everything |
frontend | pages |
api | +server.ts routes |
ws | WebSocket connections |
--scale frontend=2,api=2,ws=1 runs five workers. A WebSocket connection goes to a ws or full worker picked from the visitor's cookies, so a returning visitor lands on the same one.
Workers help when rendering is CPU-bound. Each is a full Bun process, so start with one per core at most, and measure.