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
PieceWhat it isWhere 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?

DockerNative
PostgreSQLBrought with itYours to provide
NodeNot needed on the host20 or newer, on the host
Runs underdocker composesystemd, as kern-server
UpgradesOne commandRebuild and restart
Best whenYou want the short pathYou 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:

  1. checks the machine and picks a shape — Docker if Docker is there, otherwise native;
  2. generates the three secrets;
  3. writes .env;
  4. starts the server;
  5. installs kernctl and a system-wide config pointing at this instance;
  6. waits for the server to answer;
  7. prints one line beginning kern-platform: — paste that into Kern Platform and the management app is connected.
OptionWhat it does
--dir PATHWhere to install. Default /opt/kern.
--url URLThe address people will use. Default http://<this machine's ip>:8787.
--name TEXTWhat the instance calls itself. Default the hostname.
--registration MODEopen, invite (default) or closed.
--port NThe port the API listens on. Default 8787.
--mode docker|nativeHow to run it. Default: Docker if it is installed.
--source PATH|URLWhere to get the code from. Default: this checkout.
--user NAMEThe service account, native mode. Default kern.
--yesDon'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:

WhatWhere
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.

  1. Check it is actually up. kernctl status, then curl -s http://127.0.0.1:8787/kern/v1/health.
  2. Run the checks. kernctl health will tell you what is missing — typically mail, and PUBLIC_URL if the proxy isn't up yet.
  3. 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>.
  4. Decide who else may join. See who may sign up. The default, invite, is a safe place to stand.
  5. Set up mail if anybody else will use it. Nobody but you can finish signing up without it.
  6. 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:

LineIf you leave it out
Upgrade / ConnectionNo live collaboration, and no error anywhere
proxy_http_version 1.1Same: upgrades need HTTP/1.1 upstream
client_max_body_sizeWhiteboard images fail with 413 above the default 1 MB
proxy_read_timeoutThe 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.

ModeWho gets inUse 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.

Mail

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:

GroupWhat it looks at
DatabaseConnectivity, migrations, the connection pool, record sequence integrity, orphaned records, table sizes
SecurityBoth secrets, whether the management API is exposed, management tokens
ConfigWhether PUBLIC_URL matches what is actually served
MailWhether SMTP is configured at all
StorageExpired 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:

  1. The database. Everything people have synced.
  2. .env. Specifically STREAMS_ENCRYPTION_KEY — without it, restored stream secrets, GitHub tokens and Turnstile keys are unreadable — and BETTER_AUTH_SECRET, without which everyone is signed out.
  3. 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.

SymptomUsuallyIn depth
The app can't reach the serverThe proxy, TLS, DNS, or the service isn't runningCan't reach it
Sync works, no live collaborationA proxy not passing Upgrade and ConnectionNo live collaboration
"It isn't a Kern server"A proxy error page, or the wrong portAnswered, but not Kern
Kern won't sign in over plain HTTPA public address without TLSRefuses plain HTTP
Nobody receives a codeNo SMTP — it's in the logNobody gets codes
Somebody can't sign upRegistration mode, or a domain limitCan't sign up
The management API refuses youThe address gate, not your tokenRefused
Invite links go nowherePUBLIC_URLWrong links
Logs empty after a restartThe log is a ring buffer in memoryEmpty logs
Image uploads failThe proxy's body limit, or a space quotaUploads
The disk is fillingWhiteboard imagesStorage

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.