Self-hosting
Kern Cloud is the optional online half of Kern: accounts, a synced personal space, shared workspaces, realtime collaboration, share links, calendar feeds and streams. The server is open source, so you can run the whole thing yourself, with nobody else holding the data.
This page is the guide from nothing to a server other people can safely use. Four companion
pages go deeper where you need it: every setting,
the kernctl command reference,
troubleshooting in depth, and
the protocol.
Overview
Everything is in one repository, Kern-Cloud-Self-Host, and it is the same code that runs the public server — not a cut-down edition. Accounts, sync, realtime collaboration, invitations, workspaces, whiteboard images, share links, calendar feeds and streams are all there.
Kern (desktop app) ──── TLS ────┐
│ Your Kern server
Kern Platform ───── loopback ───┤ Node + PostgreSQL
or SSH │
kernctl ─────────── loopback ───┘ /kern/v1/* the app
or SSH /kern/v1/admin/* management
| Piece | What it is | Where it runs |
|---|---|---|
server/ |
The server itself: Node and PostgreSQL, one process | Linux, macOS, Windows, Docker |
platform/ |
Kern Platform, a desktop app for managing it | Windows, macOS, Linux |
cli/ |
kernctl and the installer — the same management API, headless |
Linux and POSIX shells |
The app and the management tools use two deliberately separate surfaces. The app surface can read and write one person's work and nothing about the instance; the management surface can read everything about the instance and none of anybody's work. Neither is a superset of the other, so a leaked token on one side doesn't become the other. See the security model.
Decide the shape
Three decisions, and they are the only ones that are awkward to change later. Everything else is a setting you can revise.
Docker or native?
| Docker | Native | |
|---|---|---|
| PostgreSQL | Brought with it | Yours to provide |
| Node | Not needed on the host | 20 or newer, on the host |
| Runs under | docker compose | systemd, as kern-server |
| Upgrades | One command | Rebuild and restart |
| Best when | You want the short path | You already run PostgreSQL, or can't use Docker |
Choose Docker unless you have a reason not to. The installer picks it automatically when Docker is present.
Who will use it?
Only you, on your own devices? You can skip TLS and mail entirely: reach the server over an SSH tunnel, and read your one verification code out of the log. That is a genuinely complete setup, not a compromise.
Anybody else? You need a hostname, a certificate and working mail, in that order. Without TLS the app will refuse to sign in to a public address at all, and without mail nobody but you can finish signing up.
What goes in front of it?
The API deliberately listens on 127.0.0.1:8787 and nothing else. Something has
to publish it: Caddy (gets its own certificate, two lines), nginx (more configuration, see
below), or a tunnel such as Cloudflare's. Not changing the bind address
is the whole shape of the deployment.
What you need
- Linux, for the installer. The server itself is plain Node and runs on macOS and Windows too; only the install script is Linux-specific.
- PostgreSQL 15 or newer, or Docker, which brings its own (17).
- Node 20 or newer, for a native install. Docker doesn't need it.
- A hostname and a TLS certificate, if anybody but you will use it.
One person syncing their own devices is a very small load — a server with 1 GB of memory is
plenty. What grows is the database, and what grows fastest in it is whiteboard images,
which ASSET_QUOTA_MB caps. Tasks, notes and boards are small text and will not
trouble any disk.
Installing
With the installer
On a Linux box, one command:
sudo ./cli/install.sh --url https://kern.example.com --name "Acme Labs"
In order, it:
- checks the machine and picks a shape — Docker if Docker is there, otherwise native;
- generates the three secrets;
- writes
.env; - starts the server;
- installs
kernctland a system-wide config pointing at this instance; - waits for the server to answer;
-
prints one line beginning
kern-platform:— paste that into Kern Platform and the management app is connected.
| Option | What it does |
|---|---|
--dir PATH | Where to install. Default /opt/kern. |
--url URL | The address people will use. Default http://<this machine's ip>:8787. |
--name TEXT | What the instance calls itself. Default the hostname. |
--registration MODE | open, invite (default) or closed. |
--port N | The port the API listens on. Default 8787. |
--mode docker|native | How to run it. Default: Docker if it is installed. |
--source PATH|URL | Where to get the code from. Default: this checkout. |
--user NAME | The service account, native mode. Default kern. |
--yes | Don't ask anything. |
--uninstall [--purge] | Remove the service, and with --purge the data too. |
It is safe to run twice. An existing .env keeps its secrets,
so a second run upgrades rather than resets. Nothing is written outside the install
directory, /etc/kernctl, /usr/local/bin/kernctl and, in native
mode, the systemd unit — so you always know what to remove.
Native mode builds the server with Node and runs it under systemd. The
unit is hardened — NoNewPrivileges, ProtectSystem=strict,
ProtectHome — and the only path it can write is /var/lib/kern,
where the management token lives.
By hand, with Docker
cp .env.example .env
Four values have to be set; the rest have working defaults, each documented in the file.
POSTGRES_PASSWORD=$(openssl rand -base64 36)
BETTER_AUTH_SECRET=$(openssl rand -base64 48)
STREAMS_ENCRYPTION_KEY=$(openssl rand -base64 32)
PUBLIC_URL=https://kern.example.com
Then:
docker compose up -d --build
docker compose logs api # the first management token, printed once
Compose runs two containers and keeps state in two folders beside the compose file:
| What | Where |
|---|---|
db — PostgreSQL 17, with a health check the API waits on | ./data/postgres |
api — this server, published on 127.0.0.1:8787 only | ./data/kern holds the management token |
Both folders belong in your backups' blast radius: the token folder survives a rebuild,
which is what lets kernctl on the host read it.
Back up STREAMS_ENCRYPTION_KEY. It encrypts stream secrets,
GitHub tokens and Turnstile keys. If it is unset, a key is derived from
BETTER_AUTH_SECRET instead — which means rotating that secret silently makes
stored stream credentials unreadable.
By hand, without Docker
cd server
npm ci
npm run build
DATABASE_URL=postgres://… node dist/index.js
Migrations run at startup, every start, so there is no separate step and nothing to remember. Put it behind a process manager that restarts it, and behind a reverse proxy with TLS.
First run
Six things, in this order. Doing them in order matters: the first account is special, and opening the address before you have one means somebody else could take it.
-
Check it is actually up.
kernctl status, thencurl -s http://127.0.0.1:8787/kern/v1/health. -
Run the checks.
kernctl healthwill tell you what is missing — typically mail, andPUBLIC_URLif the proxy isn't up yet. -
Create your own account from Kern, pointed at the server. The very first
account is always allowed, whatever the registration mode says. With no mail configured,
verify it with
kernctl users verify <your email>. -
Decide who else may join. See who may sign
up. The default,
invite, is a safe place to stand. - Set up mail if anybody else will use it. Nobody but you can finish signing up without it.
- Arrange a backup before there is anything to lose — it takes one cron line. See backups.
Then keep two things somewhere safe that isn't the server: a copy of .env, and
a management token (or the token file). Both are cheap now and awkward later.
Putting TLS in front of it
The compose file publishes the API on 127.0.0.1:8787 and nothing else. That is
the whole shape of the deployment: don't change it to
0.0.0.0. Put a reverse proxy with TLS in front instead.
Beyond ordinary forwarding, the server needs exactly one thing from the proxy: the realtime
socket is a WebSocket upgrade, so Upgrade and Connection must be
passed through. Miss it and sync works while live collaboration silently
doesn't — the single most common self-hosting mistake.
Caddy
kern.example.com {
reverse_proxy 127.0.0.1:8787
}
Caddy gets the certificate itself and handles WebSockets without being asked. If you have no reason to prefer nginx, this is the shortest correct answer.
nginx
server {
listen 443 ssl http2;
server_name kern.example.com;
ssl_certificate /etc/letsencrypt/live/kern.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kern.example.com/privkey.pem;
# Whiteboard images and stream submissions; see ASSET_MAX_BYTES.
client_max_body_size 8m;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
# The realtime socket. Without these two lines sync works and live
# collaboration silently doesn't.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
Four lines there are load-bearing, and each fails in its own way:
| Line | If you leave it out |
|---|---|
Upgrade / Connection | No live collaboration, and no error anywhere |
proxy_http_version 1.1 | Same: upgrades need HTTP/1.1 upstream |
client_max_body_size | Whiteboard images fail with 413 above the default 1 MB |
proxy_read_timeout | The socket connects, then drops every minute — flaky-looking collaboration |
Then tell the server its own address, and restart:
PUBLIC_URL=https://kern.example.com
Invite links, share links and stream endpoints are built from that value, so if it still
says localhost people receive links that go nowhere.
kernctl health checks it against what the proxy actually serves, because the
failure is otherwise quiet.
Behind a tunnel
A tunnel — Cloudflare's, or anything similar — works the same way: point it at
http://127.0.0.1:8787 and set PUBLIC_URL to the public hostname.
WebSockets are usually on by default; check, because that is the setting that decides
whether live collaboration works. If the tunnel passes the visitor's address in a header,
keep the server reachable only through the tunnel — anything that can reach the
port directly could set that header itself.
Without a certificate
If you have no hostname, reach the server over an SSH tunnel and treat it as local:
ssh -L 8787:127.0.0.1:8787 you@server
Kern will then sign in to http://127.0.0.1:8787, because that genuinely
is this machine and the traffic is inside SSH. It refuses plain HTTP to a public
address: the session and everything synced would cross the internet in the clear. A private
network is allowed, but says so.
Pointing Kern at it
In Kern: Settings → Account & Cloud → Kern server → Add…. Type the address. Kern asks the server what it is before saving anything, and shows you the name it gives, so a typo or a wrong port is caught immediately rather than after a sign-in attempt.
Your account on your server is separate from every other Kern server. A session is held per server in your operating system's credential store, so pointing Kern somewhere else never carries an existing session there — the new server simply asks you to sign in.
Addresses are tidied on the way in: a bare hostname gets https://, and
/kern/v1 is added if it isn't already there. So
kern.example.com and https://kern.example.com/kern/v1/ mean the
same server.
For a managed deployment, starting Kern with KERN_CLOUD_URL set chooses the
server and locks the choice; Settings shows it as locked and says why. That is the
supported way to hand out a preconfigured build.
The server reports a protocol number, and an app refuses a server whose protocol is higher than the one it implements, naming both numbers. So if you upgrade the server past your apps, update the apps — see the handshake.
Who may sign up
REGISTRATION decides, and it is a live setting: change it from Kern Platform
or with kernctl set registration open, and apps see the difference on their
next sign-in screen, with no restart and nobody disconnected.
| Mode | Who gets in | Use it when |
|---|---|---|
open |
Anyone who can reach the server. Optionally limited to certain email domains with ALLOWED_EMAIL_DOMAINS. |
A company server on an internal domain, where invitations would be busywork. |
invite |
Only an address holding a workspace invitation, or one an administrator added. The default. | Almost everything else. |
closed |
Nobody new. | A personal server, once your own account exists. |
The very first account is always allowed, whatever this says, so a server you have just installed is usable. The flip side is the reason to create yours promptly.
With invite, to let somebody in without a workspace invitation:
kernctl allow add alice@example.com "new starter"
kernctl allow list
MAX_USERS caps how many accounts the instance will hold; 0 means
no limit. Accepting an invitation needs a verified address matching the invited one, so an
invitation can't simply be passed to somebody else.
Without SMTP, verification codes and invitations are printed to the server log instead of sent. For a server only you use that is workable — read the log, or skip the step entirely:
kernctl logs | grep -i code
kernctl users verify alice@example.com
But nobody else can finish signing up, so set SMTP before inviting anyone:
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false # true for implicit TLS on 465
SMTP_USER=kern@example.com
SMTP_PASS=…
MAIL_FROM=Kern <no-reply@example.com>
Any provider will do. Use an address on a domain with SPF and DKIM set up, or the codes will land in spam, which looks exactly like mail not working.
kernctl health reports mail as a warning while it is unset, and the app's
sign-in screen tells people this server can't send them a code — the instance handshake
carries features.email, so apps know before they try rather than after.
What goes out over SMTP: verification codes, sign-in codes, password-recovery codes, workspace invitations and "password changed" notices. Nothing else.
Email into streams
Optional, and off unless you configure it. A stream can have a private email address, so forwarding a message to it files the message as a submission — usually into the Inbox, like any other.
Inbound mail needs a mail provider in front of the server, because the server speaks SMTP
outward only. The shipped worker
(server/deploy/email-worker.js) is for Cloudflare Email Routing: deploy it,
add a catch-all rule that sends to it, and set:
INBOUND_EMAIL_SECRET=… # the same random value on the worker
INBOUND_EMAIL_DOMAIN=in.example.com
Leave the secret empty and the feature is off — the app doesn't offer addresses at all,
which is the right default. STREAM_EMAIL_PER_HOUR caps inbound volume per
stream, and a stream can be limited to a list of senders.
Managing it
Both clients speak the same management API, and anything one can do, the other can. That equivalence is deliberate: a server with no graphical session is not limited.
Kern Platform is the desktop app — health, people, spaces, streams,
settings, logs, the audit trail and a read-only SQL console. On the server, run
kernctl pair and paste the line it prints.
kernctl is the same surface in a terminal. On the server's own
machine it needs no setup: it reads the token the server wrote on first start.
kernctl status # what this instance is, and how much is in it
kernctl health --fix # every check, and the repairs that are safe
kernctl users list --unverified
kernctl pair # one line for Kern Platform
kernctl backup /srv/backups/kern.sql.gz
Every command takes --json, which needs nothing but curl. Exit
codes are meaningful: 0 fine, 1 refused, 2 called
wrongly, 3 unreachable, 4 health failing. The
command reference covers all of it.
From your laptop, forward the port and remember it:
ssh -L 8787:127.0.0.1:8787 you@server
kernctl use http://127.0.0.1:8787 --token kern_…
Opening the management API to the network
ADMIN_LOCAL_ONLY=true is the default, and it means nothing on the network can
even attempt a token. Only relax it if a tunnel genuinely won't do:
ADMIN_LOCAL_ONLY=false
ADMIN_ALLOW_FROM=192.168.1.0/24
The client address is taken from the socket, not from a header — a proxy header is attacker-controlled, and trusting one here would undo the check entirely. So putting the management API behind a reverse proxy means the proxy is what is on the allow list, and you must restrict who reaches it there instead.
Management tokens
A token reads kern_<id>_<secret>, and only the hash of the secret
half is stored: a stolen database yields no working tokens. The whole token is shown once,
when it is created.
kernctl token list
kernctl token new "monitoring" --read --days 90
kernctl token revoke <id>
kernctl audit 50
A read token can look at everything and change nothing, which is what a
monitoring check should hold. Everything that changes something is recorded in the audit
trail with the token that did it — so one token per purpose is what makes that trail worth
reading.
The security model
Worth reading once, because several defaults look inconvenient until you see what they are for.
- Two surfaces, neither a superset of the other. The app surface reaches one person's work and nothing about the instance. The management surface reaches everything about the instance and none of anybody's work — it counts records, measures spaces and lists membership, and never returns the contents of a task, note or whiteboard. A leaked management token is bad; it is not a way to read the data.
- Loopback by default. Both the API port and the management gate assume the network is not to be trusted. A reverse proxy publishes the app surface; the management surface normally isn't published at all.
- Addresses come from sockets, not headers, wherever a decision depends on them. That is why the management allow list sees your proxy rather than your laptop.
-
Secrets are stored as hashes where they can be. Management tokens are
hashed; stream credentials are encrypted with
STREAMS_ENCRYPTION_KEY; session tokens live in each client's operating-system credential store, not in a file. - The app refuses plain HTTP to a public address. Not a warning, a refusal — the session and everything synced would otherwise be readable in transit.
- Destructive management actions name their target. Deleting an account takes the email as confirmation, so a mistyped id can't take out the wrong one.
- Sync is limited by the server, not by a setting. Only known core tables sync; files, folders, repositories, terminals and settings are refused, so no client can opt into uploading them.
What is not protected: anyone who can read your database and your .env
can read everything people synced. Disk encryption and backup hygiene are yours to
arrange.
Keeping it healthy
kernctl health # every check, grouped: database, security, config, mail, storage
kernctl health --fix # and repair what can be repaired
kernctl heal # repair everything, then check again
kernctl doctor # this machine, and the path to the server
Checks cover:
| Group | What it looks at |
|---|---|
| Database | Connectivity, migrations, the connection pool, record sequence integrity, orphaned records, table sizes |
| Security | Both secrets, whether the management API is exposed, management tokens |
| Config | Whether PUBLIC_URL matches what is actually served |
| Whether SMTP is configured at all | |
| Storage | Expired sessions and verifications, stale invitations, unused whiteboard images, the stream log, spaces deleted more than 30 days ago, workspaces that have lost their owner |
The repairable ones are safe by construction: they delete what has already expired or lost
its parent. Nothing repairable touches live data.
kernctl health --fix=<id> runs one of them.
For a monitoring check, the exit code is enough — no parsing:
kernctl health --json >/dev/null || echo "kern unhealthy" # exit 4 when failing
GET /kern/v1/health is public and unauthenticated: it checks the database and
reports realtime figures, which is what a load balancer wants. Give a monitoring box its
own --read token so it can't change anything.
Backups
kernctl backup /srv/backups/kern-$(date +%F).sql.gz
kernctl restore /srv/backups/kern-2026-10-21.sql.gz
pg_dump, gzipped. Back up three things, not one:
- The database. Everything people have synced.
-
.env. SpecificallySTREAMS_ENCRYPTION_KEY— without it, restored stream secrets, GitHub tokens and Turnstile keys are unreadable — andBETTER_AUTH_SECRET, without which everyone is signed out. - The management token file, if you have no other copy of a token.
A nightly cron, keeping two weeks:
0 3 * * * /usr/local/bin/kernctl backup /srv/backups/kern-$(date +\%F).sql.gz && \
find /srv/backups -name 'kern-*.sql.gz' -mtime +14 -delete
Keep a copy somewhere that isn't this machine, and check once that a dump actually restores — an untested backup is a hope.
Restoring replaces the database, and clients notice. A space whose record
sequence has gone backwards is restored from the server on the app's next sync. That is the
right outcome — everyone converges on what the server holds — but it means changes made
after the backup are gone. Tell people first with kernctl notice, and see
after a restore.
Upgrading
Back up first; it is what makes an upgrade reversible.
kernctl backup /srv/backups/pre-upgrade-$(date +%F).sql.gz
kernctl service upgrade # docker: rebuild and restart
Or by hand:
cd /opt/kern && git pull
docker compose up -d --build
Native installs replace the files and restart:
cd /opt/kern/server && npm ci --omit=dev && npx tsc -p tsconfig.json
systemctl restart kern-server
Migrations run at startup. There is no separate migration step and no downtime window to arrange for an ordinary upgrade — though the service does restart, so send a notice if people are connected:
kernctl notice "Upgrading at 20:00; sync will pause briefly" --warn
Protocol versions. The server reports a protocol number, and an app refuses a server whose protocol is higher than the one it implements, saying so plainly rather than failing later in a way nobody can diagnose. If you upgrade the server past your apps, update the apps.
Troubleshooting
The short list. Each of these is covered in depth on the troubleshooting page.
Start here, in this order:
kernctl status
kernctl health
kernctl doctor
curl -s http://127.0.0.1:8787/kern/v1/health
If that last command answers and your app can't, the problem is the proxy or TLS between them — not the server.
| Symptom | Usually | In depth |
|---|---|---|
| The app can't reach the server | The proxy, TLS, DNS, or the service isn't running | Can't reach it |
| Sync works, no live collaboration | A proxy not passing Upgrade and Connection | No live collaboration |
| "It isn't a Kern server" | A proxy error page, or the wrong port | Answered, but not Kern |
| Kern won't sign in over plain HTTP | A public address without TLS | Refuses plain HTTP |
| Nobody receives a code | No SMTP — it's in the log | Nobody gets codes |
| Somebody can't sign up | Registration mode, or a domain limit | Can't sign up |
| The management API refuses you | The address gate, not your token | Refused |
| Invite links go nowhere | PUBLIC_URL | Wrong links |
| Logs empty after a restart | The log is a ring buffer in memory | Empty logs |
| Image uploads fail | The proxy's body limit, or a space quota | Uploads |
| The disk is filling | Whiteboard images | Storage |
Uninstalling
sudo ./cli/install.sh --uninstall # stop and remove the service
sudo ./cli/install.sh --uninstall --purge # and the data
Without --purge the database and .env are left where they are, so
you can reinstall onto them. Take a backup before using --purge; it does what
it says.
If people were syncing to this server, tell them before it goes: their work stays on their own computers, but the space stops syncing and anything they hadn't synced yet stays only where it already is. Exporting from the app first is the safe order.