Self-hosting Zeitlines
Zeitlines has two setups worth distinguishing. Without a database it serves JSON timelines and needs nothing but Node.js. With PostgreSQL the same timelines become editable, live and multi-user. Start with the first; the second is additive.
Requirements
- Node.js 22 or newer. A hard floor: older versions fail before a single test runs.
- PostgreSQL, only if you want editable timelines.
- Nothing else. No account, no licence key, no external service in the file-only path.
Files only
git clone https://github.com/zeitlines/zeitlines.git
cd zeitlines
npm install
npm run dev
Every *.json in data/ is served as a read-only
timeline named after the file. Two examples are committed, so there is
something on screen before you have written anything.
This path is also what the project's own continuous integration runs, on purpose: the build has to work for someone who has just cloned the repository and has no credentials at all. A missing database and a missing notes folder are warnings, never failures.
With PostgreSQL
# 1. a database — any Postgres will do
docker run -d -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
# 2. point at it
export TIMELINES_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres
# 3. schema
npm run db:migrate
# 4. run
npm run dev The migration runner is portable and needs no vendor CLI: it applies the committed SQL files in order and records what has run. The dev command checks first and refuses to start while a migration is pending, so the application never quietly talks to an older schema than the code expects.
To put existing data in, npm run db:import -- your-timeline
reads data/your-timeline.json straight into the database.
Two drivers, one seam
| Driver | Selected by | Suits |
|---|---|---|
| postgres.js — native TCP | TIMELINES_DATABASE_URL | Any PostgreSQL you run yourself |
| supabase-js — HTTP | TIMELINES_SUPABASE_URL + service key | A Supabase project, and the hosted deploy |
Both sit behind one interface, so nothing above them knows which is in use.
On a Supabase-backed install, schema work needs a direct connection of its
own — migrations are DDL and cannot travel over the HTTP layer — which is
what TIMELINES_MIGRATE_DATABASE_URL is for. Setting it does not
change which driver serves the application.
Live updates
Other people's edits arrive either over a websocket, when the database offers
one, or by polling a small watermark endpoint. Set
TIMELINES_DB_LIVE=poll for the second, which is the setting a
plain self-hosted Postgres wants. What the person looking at the timeline
sees is the same either way.
Configuration
Server-side variables are read from the environment first, then
.env.local, then any file you name explicitly — so credentials
can live outside the repository without any of them being read by default. A
fresh checkout reads nothing outside its own folder.
| Variable | Purpose |
|---|---|
TIMELINES_DATABASE_URL | Postgres connection string; selects the native driver |
TIMELINES_MIGRATE_DATABASE_URL | Connection used only for schema work |
TIMELINES_SUPABASE_URL / …_SERVICE_KEY | Supabase project and its service-role key |
TIMELINES_DB_LIVE | poll for live updates without a realtime service |
TIMELINES_NOTES_DIR | The Markdown folder to scan; a missing folder is non-fatal |
TIMELINES_STATIC_ONLY | Skip the notes scan entirely |
TIMELINES_SOURCES_SUBDIR | Scope which timelines a deployment serves |
AUTH_REQUIRED / ALLOWED_EMAIL_DOMAINS | The sign-in gate and the domains allowed through it |
VITE_JIRA_BASE_URL | Base URL for JIRA links; empty renders keys as plain text |
Deploying it
npm run build produces a static site. The repository also carries
a Netlify configuration with three edge functions — the auth gate, the
timelines API and the public pricing API — so a deployment can be gated
behind Google sign-in with an allowed-domain list while the pricing endpoint
stays open.
Nothing forces that host. A build with no database configured is plain static files that any web server will serve; the edge functions are what add the live database path and the gate.
The principle behind the failure modes
A database-backed timeline loads live and fails loudly. There is deliberately no cached or committed snapshot of live content, because a stale copy is indistinguishable from real data and reliably gets mistaken for it.
It is worth knowing before you operate an instance, because it explains why a broken credential produces an error message rather than a plan that quietly stopped updating three weeks ago.
Related
- Writing a timeline file by hand
- Adding the MCP server for agents
- The database chapter in the documentation
- The HTTP API, as OpenAPI 3.1
Frequently asked questions
- How do I self-host a Gantt chart tool?
- Clone the Zeitlines repository, run
npm installandnpm run dev. That serves every JSON timeline in the data folder with no database and no configuration. Adding Postgres turns the timelines editable; deploying the built output puts them on a URL. - What are the requirements?
- Node.js 22 or newer. Node 22 is a hard floor rather than a preference — the test runner relies on glob expansion that older versions do not do. PostgreSQL is optional and only needed for editable timelines.
- Does Zeitlines require Supabase?
- No. Supabase is one of two interchangeable database drivers. The other talks to any PostgreSQL over a plain connection string, which is what a self-hosted install typically uses. The application code never sees which one is in play.
- Can I get live updates without Supabase Realtime?
- Yes. Run the server with
TIMELINES_DB_LIVE=polland the client polls a cheap watermark endpoint instead of holding a websocket open. Same effect for the person looking at the timeline, no realtime service required. - Can I put the deployment behind a login?
- Yes, on Netlify: set
AUTH_REQUIRED=trueand list the e-mail domains allowed to sign in. An empty list means nobody passes, which is the safe direction to fail in. The public pricing endpoint stays reachable, by design. - What happens if the database is unreachable?
- The page says so. There is deliberately no cached or committed snapshot to fall back to, because a stale copy of a plan is indistinguishable from a current one and reliably gets mistaken for it.