Self-hosting Zeitlines
Node.js puts your timelines on screen from a filesystem or a database. Local JSON and Markdown sources stay editable on your machine. PostgreSQL keeps shared plans editable after deployment and available to remote MCP clients.
Requirements
- Node.js 22 or newer. A hard floor: older versions fail before a single test runs.
- PostgreSQL, optional. Option 1 never needs it. It is what option 2 is made of, so you need it once a timeline should stay editable after it is deployed.
- Nothing else. No account, no licence key, no external service for a filesystem timeline.
Option 1: Filesystem
git clone https://github.com/zeitlines/zeitlines.git
cd zeitlines
npm install
npm run dev
A timeline here is a file you own: one *.json in
data/, named after the file, or a folder holding one Markdown
note per item with a timeline.json beside them. Example
timelines are committed, so there is something on screen before you have
written anything.
The dev server can write the file it read, so the timeline is editable there:
drag a bar and the JSON, or the note's frontmatter, changes on disk. A
deployment serves it read-only, because no deployment writes files, neither a
static host nor npm start. Edits then happen where the data is,
from an editor, from Git or through the MCP server, and reach the site with
the next build.
This is the path the project's own continuous integration runs, so it stays working for somebody who has just cloned the repository and has no credentials at all.
A folder you already own, including an Obsidian vault
data/ inside the checkout is the default and not the limit.
TIMELINES_LOCAL_ROOT takes an absolute path (~ is
expanded) and moves the source root anywhere on disk, which is what lets a
timeline be a directory that exists for its own reasons: a notes folder, an
Obsidian vault, the folder somebody is writing a novel in.
export TIMELINES_LOCAL_ROOT=~/Documents/Vault
npm run dev
Every folder under that root carrying a timeline.json becomes a
timeline, and each Markdown note in it becomes an item, its frontmatter
supplying the date, the group, the status and whatever else you already
write. That file may be {}: its job is to mark which
folder you meant. Folders without one are passed over, so a vault gives you
the timelines you asked for rather than one per directory.
Nothing is imported and nothing is copied, and the tools' own directories
(.obsidian, .trash, .git) are left
out. The notes stay the thing you edit; the timeline is a way of looking at
them. While npm run dev runs it goes both ways: a note you save
in Obsidian reaches the timeline, an edit in the timeline reaches the note,
so the two windows can stay open beside each other.
The folder declares how it is read
A folder of meeting notes and a folder holding a novel answer the same
questions differently, so the answers live in that folder's
timeline.json rather than in an environment variable. Moving or
copying the folder takes them along.
{
"name": "Book one",
"scan": {
"dateFields": [],
"groupFromFolder": true,
"linkEdges": true
}
} | Key | What it decides |
|---|---|
dateFields |
Frontmatter keys tried in order for an item's start, by default
date, scheduled, created. Set it
to [] for a vault that stamps created on
every note, which would otherwise put every item on the day it was
typed.
|
filenameDatePatterns | Date patterns in the filename, tried when the frontmatter carries none, so a daily note called 2026-01-15-standup.md lands on the right day. |
groupFromFolder | Take an item's group from the subfolder it sits in when its frontmatter names none. Off by default. |
linkEdges | Read the [[wikilinks]] you already wrote as relations, which become the Gantt chart's arrows and the graph's edges. Off by default, because in most folders a link is a reference rather than a dependency. |
How each of them resolves in detail is in the local-sources chapter.
Reading is not writing
TIMELINES_LOCAL_READONLY=1 refuses every write for a whole
instance, and the interface stops offering the editing controls. That is the
setting for a folder you write prose in, where a mis-drag must not reach a
note.
Where writes are allowed, an edit changes the one frontmatter key it has to
and leaves the rest of the file exactly as it was, body included. Deleting an
item moves the note to .trash/, Obsidian's own convention,
rather than deleting a file the tool did not create.
Option 2: Database
Option 1 is editable where the files are, which is your own machine. A database is what carries that to a URL: the timeline stays editable after it is deployed, for everybody who opens it, and their edits reach each other while they work. It is additive, so a filesystem timeline keeps working beside a database one on the same instance.
It runs on PostgreSQL. Both drivers below speak Postgres and Supabase is Postgres, so a managed project and a container you started yourself are the same timeline as far as the application is concerned.
# 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 needs no vendor CLI, and the dev command 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.
A Supabase-backed install needs a direct connection for schema work on top,
which is what TIMELINES_MIGRATE_DATABASE_URL is for; 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.
When the database is unreachable
The page says so and shows nothing. There is deliberately no cached snapshot to fall back to, because a stale copy of a plan is indistinguishable from a current one and gets mistaken for it. A broken credential therefore produces an error message rather than a plan that quietly stopped updating three weeks ago.
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_LOCAL_ROOT | Where local sources are discovered, default data/. An absolute path points the instance at a notes folder or Obsidian vault you already own |
TIMELINES_LOCAL_READONLY | Refuses every write to a local source, even where the runtime could perform one |
TIMELINES_SOURCES_SUBDIR | Scope which timelines a deployment serves |
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 |
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 sit 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 static files
that any web server will serve, and npm start serves the same
build plus the API from one Node process. The edge functions are what add the
sign-in gate on Netlify.
Related
- Writing a timeline file by hand
- Adding the MCP server for agents
- Local sources in the documentation, folders and their scan block
- 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 timeline in the data folder with no database and no configuration. A deployment serves those files read-only, so a database is what keeps a timeline editable once it is on a URL. - Can I point Zeitlines at my Obsidian vault without copying it?
- Yes, and that is the intended way to use it. Point
TIMELINES_LOCAL_ROOTat the vault, and every folder in it holding atimeline.jsonbecomes a timeline whose items are its Markdown notes. There is no import step and no second copy of your notes. - Will Zeitlines change my notes?
- Only if you edit the timeline yourself, on a machine that has the vault. An edit then changes the one frontmatter key it has to and leaves the rest of the file exactly as it was; deleting an item moves the note to
.trash/.TIMELINES_LOCAL_READONLY=1refuses every write for the whole instance, which is the setting for a folder you write prose in. - 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 needed only for a timeline that stays editable in a deployment.
- 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.