Skip to content
Zeitlines

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

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

DriverSelected bySuits
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.

VariablePurpose
TIMELINES_DATABASE_URLPostgres connection string; selects the native driver
TIMELINES_MIGRATE_DATABASE_URLConnection used only for schema work
TIMELINES_SUPABASE_URL / …_SERVICE_KEYSupabase project and its service-role key
TIMELINES_DB_LIVEpoll for live updates without a realtime service
TIMELINES_NOTES_DIRThe Markdown folder to scan; a missing folder is non-fatal
TIMELINES_STATIC_ONLYSkip the notes scan entirely
TIMELINES_SOURCES_SUBDIRScope which timelines a deployment serves
AUTH_REQUIRED / ALLOWED_EMAIL_DOMAINSThe sign-in gate and the domains allowed through it
VITE_JIRA_BASE_URLBase 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

Frequently asked questions

How do I self-host a Gantt chart tool?
Clone the Zeitlines repository, run npm install and npm 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=poll and 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=true and 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.