Can I self-host Uptime.com?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Kener. It takes one prompt, a 1024 MB VPS, and about 80 minutes. That is $9 a month you stop paying Uptime.com — $108 a year on the Website Monitoring · 10 basic checks plan, a metered rate, not a whole bill.
Why people pay for Uptime.com
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Uptime.com checks your site from places that are not your site, and that is the half a single self-hosted instance cannot honestly reproduce: even the entry subscription runs each check from three of twelve locations, so a regional outage looks different from a dead server. The bill also buys transaction checks that click through a signup flow, SMS alerts that still arrive on the morning your own infrastructure is what broke, and a hosted status page with a subscriber list somebody else keeps online. It is sold as four separate quantity sliders added to one subscription rather than as named plans, which is why the second line of your invoice is usually the status page.
| Plan | List price | What it buys |
|---|---|---|
| Website Monitoring · 10 basic checksthe plan this page prices against | $9/mo metered | The first step on the website-monitoring slider: 10 basic checks, 1 advanced check, 25 SMS alerts. $84/yr billed annually, which is $7/mo. |
| Website Monitoring · 50 basic checks | $38/mo metered | 50 basic checks, 5 advanced checks, 75 SMS alerts. $360/yr billed annually. |
| Website Monitoring · 200 basic checks | $150/mo metered | 200 basic checks, 15 advanced checks, 200 SMS alerts. $1,440/yr billed annually. The slider continues past this to 30,000 basic checks at $13,343/mo. |
| Status Pages · 1 page | $13/mo metered | A separate module added to the same subscription: 1 status page, 250 subscribers. $120/yr billed annually. The slider continues to 100 pages and 150,000 subscribers at $513/mo. |
| Real User Monitoring · 50,000 data points | $7/mo metered | Another module on the same subscription, measured in RUM data points per month. $60/yr billed annually. The slider continues to 10 million data points at $938/mo. |
| Probe Server Locations · 86 locations | $38/mo metered | Widens where the checks run from: 86 check locations, 5 locations per check. $360/yr billed annually. The first step of this module costs nothing and already gives 12 locations at 3 per check, so every paid subscription watches from outside your network by default. |
| Enterprise | quote only | Quote only. The pricing page offers custom pricing for large organisations rather than a figure. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by Kener
One project, named before the prompt, so you know what you are about to install.
A public status page with the incidents, the maintenance windows and the monitors behind it, all managed in a browser rather than a config file.
The closest thing here to what an Uptime.com subscription is actually for: a status page you would show customers, with the monitoring behind it rather than bolted on. Monitors, incidents, maintenance windows and multiple pages are managed in a signed-in browser, so declaring an incident is a form rather than a commit, and the page a visitor sees carries the incident timeline that most self-hosted monitors leave out. What you take on is two containers instead of one, because Redis is not optional in v4, and a single vantage point where Uptime.com sells twelve.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Kenerour pickONE EVENING— setup effort 2 of 4
A public status page with the incidents, the maintenance windows and the monitors behind it, all managed in a browser rather than a config file.
The closest thing here to what an Uptime.com subscription is actually for: a status page you would show customers, with the monitoring behind it rather than bolted on. Monitors, incidents, maintenance windows and multiple pages are managed in a signed-in browser, so declaring an incident is a form rather than a commit, and the page a visitor sees carries the incident timeline that most self-hosted monitors leave out. What you take on is two containers instead of one, because Redis is not optional in v4, and a single vantage point where Uptime.com sells twelve.
- GatusONE COMMAND— setup effort 1 of 4
A public status page whose entire configuration is one YAML file you own: the checks, the pass conditions and the alerting, declared once and served as a page.
The one to pick if the checks belong in version control. Every monitor, pass condition and alert rule lives in one YAML file you own, and the conditions go past `is it 200` to response time, JSON body fields, certificate expiry and DNS records, which is closer to what Uptime.com's advanced checks are sold for. The status page is plainer than Kener's and there is no incident timeline to publish, so it ranks second here for the reader who came looking for the page rather than the checks.
- Uptime KumaONE COMMAND— setup effort 1 of 4
Uptime monitoring and status pages from one container, with no monitor quota and no per-check billing.
The broadest notification support of the three and the fastest to get a monitor running, from one container with no Redis and no database to operate. Its status pages exist but are the side feature rather than the product, so pick it when the alerts matter more than what the public sees. The honest line applies to all three and to this one first, because it is the one people run on the same box as everything else: a monitor cannot report the outage that takes down the machine it is running on.
The swap
You'd run
Kener
ONE EVENING · ~80 min to running · 1024 MB RAM
Uptime.com Website Monitoring · 10 basic checks · a metered rate, not a whole bill · vendor list price · checked 2026-08-07 · source · confidence: medium
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~80 min1–3 hours, through the first backup
The prompt
Two paths to the same Kener: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
328 lines · 14,920 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Kener 4.1.2 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and the same hostname becomes `ORIGIN` in
step 3.
Say two things to the user before anything installs. One: a monitor cannot tell them that the
machine it runs on is down, so this belongs on a box other than the ones it watches, and they
should keep one free external check pointed at this hostname from a service they do not run.
Two: what goes on that hostname is a public status page. It answers everybody by design, and
every monitor name and description they add later is published to whoever loads it. The manage
screens behind it are not public, and step 7 closes the one window where that is not yet true.
Kener needs 1024 MB of RAM available and 5 GB free on /srv. Both images publish amd64 and
arm64. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve.
## 2. Layout
Three directories, three owners, and the ownership is the part that matters. The Kener image
runs as the `node` user, uid 1000, so it cannot write a directory owned by the login user. The
redis image chowns its own data directory on first start, so that one is left to root.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/kener /srv/kener/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/kener/database
sudo install -d -m 750 /srv/kener/redis
ls -la /srv/kener
```
Assert: `ls -la` shows `backups` owned by the login user, `database` owned by `1000`, and
`redis` owned by root. Nothing is written outside /srv/kener. Keep `database` on local disk:
`kener.sqlite.db` is a SQLite file and a network mount corrupts one quietly, weeks later.
## 3. Secrets
One secret, `KENER_SECRET_KEY`. Upstream documents it as the key that signs the session tokens
and encrypts stored credentials, and documents `openssl rand -base64 32` as the way to make
one. Generate it on the server. Do not print it, do not repeat it in your summary, and do not
put it in any log line.
`ORIGIN` goes in the same file and is not a secret. It is the public URL SvelteKit compares
against on every form post, with no trailing slash, and step 10 is what happens when it is
wrong.
```bash
umask 077
cat > /srv/kener/.env <<EOF
ORIGIN=https://<DOMAIN>
TZ=UTC
KENER_SECRET_KEY=$(openssl rand -base64 32)
EOF
chmod 600 /srv/kener/.env
umask 022
ls -l /srv/kener/.env
```
Assert: the file exists with mode `-rw-------`, and `ORIGIN` reads `https://` followed by the
real hostname. Tell the user the key is in /srv/kener/.env, that they can read it themselves
with `sudo grep KENER_SECRET_KEY /srv/kener/.env`, and that changing it later signs everyone
out and makes anything Kener had encrypted with it unreadable, so it belongs in their password
manager today.
## 4. compose.yml
```bash
cat > /srv/kener/compose.yml <<'EOF'
# Kener · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# deployment ......... https://kener.ing/docs/v4/setup/deployment
# environment ........ https://kener.ing/docs/v4/setup/environment-variables
# database ........... https://kener.ing/docs/v4/setup/database-setup
# image build ........ https://github.com/rajnandan1/kener/blob/v4.1.2/Dockerfile
#
# Two services. Redis is not optional in v4: upstream's own compose file calls
# it required for the queues, the cache and the scheduler, and the environment
# reference marks REDIS_URL required, so a Kener with no Redis runs no checks.
# No database container: DATABASE_URL is left unset, so upstream defaults to
# SQLite at ./database/kener.sqlite.db, the mount below. The image runs as the
# node user, uid 1000, which is why step 2 hands database/ to 1000. Digests
# read from the registries on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
container_name: kener-redis
restart: unless-stopped
volumes:
# Queue and scheduler state only: monitors, incidents and every result
# live in the SQLite file below. The image chowns this on first start.
- /srv/kener/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# No `ports:` at all: 6379 is reachable only from the other container.
kener:
image: ghcr.io/rajnandan1/kener:v4.1.2@sha256:239ab635b900b3fe0a4e22f5599f77525211d1b7a25b8fcf3d173936ddacc1cc
container_name: kener
restart: unless-stopped
env_file: /srv/kener/.env
environment:
# Redis is on the compose network only, so this carries no credential.
REDIS_URL: redis://redis:6379
NODE_ENV: production
volumes:
# kener.sqlite.db: every monitor, incident, page, user and result.
- /srv/kener/database:/app/database
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8179.
- "127.0.0.1:8179:3000"
depends_on:
redis:
condition: service_healthy
EOF
cd /srv/kener && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port. Do not add a Postgres
service: `DATABASE_URL` is unset on purpose, upstream then uses SQLite in the mounted
`database` directory, and that one file is what step 8 backs up.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-kener
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Kener · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://kener.ing/docs/v4/setup/deployment and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That same hostname is
# ORIGIN in .env: Kener hands ORIGIN to SvelteKit for the cross-site check on
# every form post, so if the two disagree the page loads and the sign-in fails.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
# The status page is public and the manage screens behind it are not,
# and both share a hostname. SAMEORIGIN protects the second, at the cost
# of the badge embeds Kener offers for other people's sites.
X-Frame-Options "SAMEORIGIN"
# Upstream reads the Origin header when the browser sends one and falls
# back to SameSite=Lax cookies when it does not, so this costs nothing.
Referrer-Policy "no-referrer"
-Server
}
# 8179 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8179
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-kener, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own, so there is nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8179 stays closed because compose binds it to 127.0.0.1, and 6379 stays
closed because compose never publishes it, so Redis has no host port a rule could apply to. The
checks are outbound and need nothing opened. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule mentioning 8179, 6379 or 3000.
## 7. Start and verify
Kener runs its migrations and its seed on the way up, so the first boot writes the schema and a
starter status page before it answers anything. Use the loop, not a fixed sleep.
```bash
cd /srv/kener
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' 'https://<DOMAIN>/healthcheck?strict=1'); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthcheck; echo
curl -sSL https://<DOMAIN>/ | grep -c 'Service Status'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/monitors
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Create Admin Account'
```
Assert all five, and print what you received for each. The loop ends printing `200`, and it
asks with `?strict=1` on purpose: upstream answers `200` from `/healthcheck` even when a
dependency is down, and only the strict form turns that into `503`. The plain call then prints
`{"status":"ok","db":true,"redis":true}`, and all three fields matter. The third command prints
a number greater than `0`, because `Service Status` is the heading the seeded status page
renders. The API call prints `401`, upstream's answer to a request carrying no bearer token,
and that is the security assert in this block. The last prints a number greater than `0`,
because with no users yet the sign-in page is a `Create Admin Account` form. If any of the five
misses, stop, run `docker compose logs --tail 40 kener`, then
`docker compose logs --tail 20 redis`, and name the likely earlier step: a Redis that never
reports healthy holds the app in `depends_on` and points at step 4, and a 502 from Caddy with
both containers up points at step 5. A running container is not success.
That `Create Admin Account` form is open to whoever loads the hostname first. Close it now.
STOP: tell the user to open https://<DOMAIN>/account/signin, fill in a name, an email address
and a password to create the administrator account, and save that password in their password
manager. Wait. Do not continue until they confirm.
Once they confirm, prove the window is shut:
```bash
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Create Admin Account'
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Sign In'
```
Assert: the first prints `0` and the second prints a number greater than `0`. Upstream's signup
action refuses once one user exists, so that page is a login form from here on. Both asserts
must pass before you report success.
## 8. First backup and restore
One archive: the SQLite database, the environment file, the compose file and the live Caddy
site block. Redis is not in it, because it holds queue and scheduler state that rebuilds
itself, while every monitor, incident, page, user and result is in the SQLite file.
```bash
cd /srv/kener
docker compose stop
sudo tar -czf /srv/kener/backups/kener-$(date +%F).tar.gz -C /srv/kener database .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/kener/backups/
```
Assert: the archive exists and is non-empty. Print its size. The containers are stopped on
purpose and downtime is a few seconds, because a SQLite file copied mid-write is not a backup.
A backup on the same disk as the data is not a backup. Run this one from the user's machine,
not the server:
```bash
mkdir -p ~/backups/kener
scp vps:/srv/kener/backups/*.tar.gz ~/backups/kener/
```
To restore: `docker compose down`, `sudo rm -rf /srv/kener/database`, recreate that directory
as in step 2, untar the archive back into /srv/kener, put the Caddy block back if that is what
was lost, then `docker compose up -d`. Tell the user what the archive holds in plain terms:
`database/kener.sqlite.db` is their monitors, their incident history and their administrator
account, and `.env` is the key that decrypts what Kener stored under it, so the two are worth
nothing apart.
## 9. Updating later
New versions are listed at https://github.com/rajnandan1/kener/releases. The release tag and
the image tag are the same string, so release `v4.1.3` is image tag `v4.1.3`. Take a backup
first, then edit the image line in /srv/kener/compose.yml to the new tag and its digest:
```bash
cd /srv/kener
docker compose pull
docker compose up -d
docker compose logs --tail 30 kener
```
Kener runs its migrations on the way up, so watch that log until it settles, then re-run step
7's health check and the `Service Status` grep before calling the update done.
## 10. What will probably go wrong
The sign-in that fails on a page that loads. I had `ORIGIN` set with a trailing slash, and
everything else looked right: the status page rendered, the health check said ok, the
`Create Admin Account` form appeared. Submitting it returned a bare
`Cross-site POST form submissions are forbidden`, and I spent twenty minutes in Caddy's logs
looking for a problem that was one character in .env. Kener hands `ORIGIN` to SvelteKit, which
compares it against the host the browser used on every form post; a trailing slash, an `http://`
where the browser said `https://`, or a `www.` on one side only are all mismatches. If step 7's
STOP will not complete, run `grep ORIGIN /srv/kener/.env` first, fix it, then
`docker compose up -d --force-recreate` before touching anything else.
## 11. Out of scope
- Do not set `DATABASE_URL` and do not add a Postgres or MySQL service. SQLite is the choice
here, and it is why the whole install is one directory and one file to copy.
- Do not configure SMTP, `RESEND_API_KEY`, or any alerting provider. Every one of them is an
account or a key somewhere else, and the user wires those up in the manage screens.
- Do not set `KENER_BASE_PATH` and do not switch to a `-status` image variant. Those exist for
serving Kener under a subpath of a hostname that already has a site, and they are a different
image built at a different base path.
- Do not publish 3000 or 6379 on the host and do not open either in the firewall. Caddy is the
only way in, and Redis is reachable only from the other container.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Kener 4.1.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.
Read these two before step 1, because together they decide whether you want this at all. A
monitor cannot tell you that the machine it runs on is down, so this belongs on a box other
than the ones it watches, and you should keep one free external check pointed at this hostname
from a service you do not run. And what you are building is a public status page: the dashboard
answers everybody by design, and every monitor name and description you add later is published
to whoever loads it. The sign-in and manage screens behind it are not public, and step 7 closes
the one window where that distinction does not yet hold.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Pick that hostname
carefully for a second reason too: it becomes `ORIGIN` in step 3, the address Kener compares
every form post against, so changing it later means editing .env and restarting.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/kener /srv/kener/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/kener/database
sudo install -d -m 750 /srv/kener/redis
ls -la /srv/kener
```
You should see: `backups` owned by you, `database` owned by `1000`, and `redis` owned by
`root`.
If you do not: those three owners are deliberate and none of them is you by accident. The Kener
image runs as its own `node` user, uid 1000, so a `database` directory owned by your login user
is one the container cannot write, and the first boot fails with a permission error about
`kener.sqlite.db`. The redis image chowns its own data directory the first time it starts, so
leaving `redis` to root is correct. Keep `/srv/kener/database` on the server's local disk: it
is a SQLite file, and a network mount corrupts one quietly, weeks later.
## 3. Secrets
One secret here, `KENER_SECRET_KEY`. Upstream documents it as the key that signs your session
tokens and encrypts stored credentials, and documents `openssl rand -base64 32` as the way to
make one. It is generated on the server and written straight into a file only you can read.
`ORIGIN` sits in the same file and is not a secret: it is the public URL SvelteKit compares
against on every form post, and it takes no trailing slash.
```bash
umask 077
cat > /srv/kener/.env <<EOF
ORIGIN=https://<DOMAIN>
TZ=UTC
KENER_SECRET_KEY=$(openssl rand -base64 32)
EOF
chmod 600 /srv/kener/.env
umask 022
ls -l /srv/kener/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/kener/.env` and carry
on. If the file already existed from an earlier attempt, this block has now replaced the key,
which is harmless before anyone has signed in and disruptive afterwards: a new key invalidates
every session and makes anything Kener encrypted under the old one unreadable.
Do not paste that file, the key, or any command output containing it into this chat window.
Read the key once with `sudo grep KENER_SECRET_KEY /srv/kener/.env`, put it in your password
manager, and close the terminal scrollback if you share screenshots.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/kener/compose.yml <<'EOF'
# Kener · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# deployment ......... https://kener.ing/docs/v4/setup/deployment
# environment ........ https://kener.ing/docs/v4/setup/environment-variables
# database ........... https://kener.ing/docs/v4/setup/database-setup
# image build ........ https://github.com/rajnandan1/kener/blob/v4.1.2/Dockerfile
#
# Two services. Redis is not optional in v4: upstream's own compose file calls
# it required for the queues, the cache and the scheduler, and the environment
# reference marks REDIS_URL required, so a Kener with no Redis runs no checks.
# No database container: DATABASE_URL is left unset, so upstream defaults to
# SQLite at ./database/kener.sqlite.db, the mount below. The image runs as the
# node user, uid 1000, which is why step 2 hands database/ to 1000. Digests
# read from the registries on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
container_name: kener-redis
restart: unless-stopped
volumes:
# Queue and scheduler state only: monitors, incidents and every result
# live in the SQLite file below. The image chowns this on first start.
- /srv/kener/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# No `ports:` at all: 6379 is reachable only from the other container.
kener:
image: ghcr.io/rajnandan1/kener:v4.1.2@sha256:239ab635b900b3fe0a4e22f5599f77525211d1b7a25b8fcf3d173936ddacc1cc
container_name: kener
restart: unless-stopped
env_file: /srv/kener/.env
environment:
# Redis is on the compose network only, so this carries no credential.
REDIS_URL: redis://redis:6379
NODE_ENV: production
volumes:
# kener.sqlite.db: every monitor, incident, page, user and result.
- /srv/kener/database:/app/database
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8179.
- "127.0.0.1:8179:3000"
depends_on:
redis:
condition: service_healthy
EOF
cd /srv/kener && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/kener/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/kener/compose.yml` and paste again in one go. There is no Postgres service in this
file and that is correct. `DATABASE_URL` is left unset, upstream then uses SQLite in the mounted
`database` directory, and that one file is the whole of your data.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-kener
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Kener · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://kener.ing/docs/v4/setup/deployment and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That same hostname is
# ORIGIN in .env: Kener hands ORIGIN to SvelteKit for the cross-site check on
# every form post, so if the two disagree the page loads and the sign-in fails.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
# The status page is public and the manage screens behind it are not,
# and both share a hostname. SAMEORIGIN protects the second, at the cost
# of the badge embeds Kener offers for other people's sites.
X-Frame-Options "SAMEORIGIN"
# Upstream reads the Origin header when the browser sends one and falls
# back to SameSite=Lax cookies when it does not, so this costs nothing.
Referrer-Policy "no-referrer"
-Server
}
# 8179 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8179
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-kener /etc/caddy/Caddyfile`, reload,
and paste again. The commonest cause is a `<DOMAIN>` you replaced in the comment but left in
the site line. Caddy requests the certificate on the first request to the hostname and renews
it on its own, so there is nothing to schedule.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8179`, `6379` or `3000`.
If you do not: delete anything for `8179` with `sudo ufw delete allow 8179`. Compose binds it
to 127.0.0.1, so a rule for it would cover traffic that cannot arrive, and 6379 is never
published at all, so Redis has no host port a rule could apply to. Your checks need nothing
opened either: they are outbound requests from the container, and ufw governs what arrives.
`Status: inactive` is a different problem, because Prompt Zero left this firewall on, so
something has turned it off since; `sudo ufw enable` puts it back.
## 7. Start and verify
Kener runs its migrations and its seed on the way up, so the first boot writes the schema and a
starter status page before it answers anything. The loop below is why there is no fixed sleep.
```bash
cd /srv/kener
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' 'https://<DOMAIN>/healthcheck?strict=1'); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthcheck; echo
curl -sSL https://<DOMAIN>/ | grep -c 'Service Status'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/monitors
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Create Admin Account'
```
You should see, in order: the loop reaching `200`, then
`{"status":"ok","db":true,"redis":true}`, then a number greater than `0` because
`Service Status` is the heading the seeded status page renders, then `401`, then another number
greater than `0`.
If you do not: the `?strict=1` on the loop is the part worth understanding. Upstream answers
`200` from `/healthcheck` even when the database or Redis is down, printing `"status":"degraded"`
in the body instead, and only the strict form turns a sick dependency into a `503`. So a bare
`200` proves nothing on its own; the body is the assert. The `401` is good news too: it means
the API refused a call carrying no bearer token. A `404` in place of the first three means Caddy
is reaching something other than Kener, so check `docker compose ps`. If the Kener container
never starts, run `docker compose logs --tail 20 redis` first, because a Redis that never
reports healthy holds the app back through `depends_on`, then
`docker compose logs --tail 40 kener`.
That last number is the one with a deadline on it. With no users in the database yet,
https://<DOMAIN>/account/signin is a `Create Admin Account` form, and it is open to whoever
loads the hostname first. Open it now, fill in a name, an email address and a password, and put
that password in your password manager. Then prove the window is shut:
```bash
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Create Admin Account'
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Sign In'
```
You should see: `0` from the first, and a number greater than `0` from the second.
If you do not: a `Create Admin Account` form that is still there means the account was not
created, not that the check is wrong. Upstream's signup action refuses once one user exists, so
there is no setting to switch and no second account to worry about. A running container was
never the finish line; these two numbers are.
## 8. First backup and restore
One archive: the SQLite database, the environment file, the compose file and the live Caddy
site block. Redis is not in it, because it holds queue and scheduler state that rebuilds
itself, while every monitor, incident, page, user and result is in the SQLite file.
```bash
cd /srv/kener
docker compose stop
sudo tar -czf /srv/kener/backups/kener-$(date +%F).tar.gz -C /srv/kener database .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/kener/backups/
```
You should see: one file, a few hundred kilobytes on a fresh install. The containers go down
for a few seconds on purpose, because a SQLite file copied mid-write is not a backup.
If you do not: an archive of about 100 bytes means `tar` found none of the paths, which happens
if a `-C` argument has a typo. Run `tar -tzf` on it and read what it actually contains; you
want `database/`, `.env`, `compose.yml` and `Caddyfile` in the listing.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/kener
scp vps:/srv/kener/backups/*.tar.gz ~/backups/kener/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/kener/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty status page:
```bash
cd /srv/kener
docker compose down
sudo rm -rf /srv/kener/database
sudo install -d -m 750 -o 1000 -g 1000 /srv/kener/database
sudo tar -C /srv/kener -xzf /srv/kener/backups/kener-$(date +%F).tar.gz database .env compose.yml
docker compose up -d
sleep 30
curl -sSL https://<DOMAIN>/account/signin | grep -c 'Sign In'
```
You should see: a number greater than `0`, from a database directory that was deleted a minute
ago, which means your administrator account came back with it.
If you do not: `tar: database: Not found in archive` means the date in the filename does not
match, so run `ls /srv/kener/backups/` and use the real one. If the page offers to create an
admin account again, the restore put back an empty database and you are looking at a fresh
install. Know what the two halves are worth: `database/kener.sqlite.db` is your monitors, your
incident history and your account, and `.env` is the key that decrypts what Kener stored under
it. Neither is much use without the other.
## 9. Updating later
New versions are listed at https://github.com/rajnandan1/kener/releases. The release tag and
the image tag are the same string, so release `v4.1.3` is image tag `v4.1.3`. Take a backup
first, then edit the `image:` line in /srv/kener/compose.yml to the new tag and its digest.
```bash
cd /srv/kener
docker compose pull
docker compose up -d
docker compose logs --tail 30 kener
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Kener runs its
migrations on the way up, so watch that log until it settles, then re-run step 7's health check
and the `Service Status` grep before you call the update done.
## 10. What will probably go wrong
The sign-in that fails on a page that loads. I had `ORIGIN` set with a trailing slash, and
everything else looked right: the status page rendered, the health check said ok, the
`Create Admin Account` form appeared. Submitting it returned a bare
`Cross-site POST form submissions are forbidden`, and I spent twenty minutes in Caddy's logs
looking for a problem that was one character in .env. Kener hands `ORIGIN` to SvelteKit, which
compares it against the host the browser used on every form post; a trailing slash, an `http://`
where the browser said `https://`, or a `www.` on one side only are all mismatches. If step 7's
account creation will not complete, run `grep ORIGIN /srv/kener/.env` first, fix it, then
`docker compose up -d --force-recreate` before touching anything else.
## 11. Out of scope
- Do not set `DATABASE_URL` and do not add a Postgres or MySQL service. SQLite is the choice
here, and it is why the whole install is one directory and one file to copy.
- Do not configure SMTP, `RESEND_API_KEY`, or any alerting provider. Every one of them is an
account or a key somewhere else, and you wire those up in the manage screens.
- Do not set `KENER_BASE_PATH` and do not switch to a `-status` image variant. Those exist for
serving Kener under a subpath of a hostname that already has a site, and they are a different
image built at a different base path.
- Do not publish 3000 or 6379 on the host and do not open either in the firewall. Caddy is the
only way in, and Redis is reachable only from the other container.325 lines · 14,997 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Kener 4.1.2 and the Redis its scheduler needs under ~/selfhost/kener, answering at
http://localhost:8179.
## 1. Preflight
Say this to the user before step 2, because it decides whether they want this install at all.
A status page exists so other people can read it, and this one answers at
http://localhost:8179: this computer and nothing else, not a colleague, not their own phone.
Checks also run only while this machine is awake, and a closed laptop records nothing. What
they get is a private dashboard over their own sites.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Kener plus Redis needs 1024 MB of RAM
available and 5 GB free on the home disk, and both images publish amd64 and arm64. On macOS and
Windows the memory figure is the host's, and Docker Desktop takes its allocation out of that.
If either floor is missed, print both numbers and stop. Do not install and hope.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/kener/database ~/selfhost/kener/backups
ls -la ~/selfhost/kener
```
On Linux only, `./database` is a real directory and the image runs as uid 1000, so hand it
over:
```bash
sudo chown -R 1000:1000 ~/selfhost/kener/database
```
Do not run that on macOS or Windows: Docker Desktop rewrites ownership across its file share,
so the container already sees itself as the owner. Assert: `ls -la` shows `database` and
`backups`, and on Linux `database` belongs to `1000`. Keep `database` on the local disk and
out of any synced folder: `kener.sqlite.db` needs real POSIX file locks.
## 4. Secrets
One secret, `KENER_SECRET_KEY`. Upstream documents it as the key that signs the session tokens
and encrypts stored credentials, and documents `openssl rand -base64 32` as the way to make
one. Git Bash ships openssl, so this runs the same on all three systems. Generate it here,
print it nowhere, and keep it out of your summary and any log.
`ORIGIN` is in the same file and is not a secret: it is what SvelteKit compares against on
every form post, http://localhost:8179, with no trailing slash.
```bash
umask 077
cat > ~/selfhost/kener/.env <<EOF
ORIGIN=http://localhost:8179
TZ=UTC
KENER_SECRET_KEY=$(openssl rand -base64 32)
EOF
chmod 600 ~/selfhost/kener/.env
umask 022
ls -l ~/selfhost/kener/.env
```
Assert: the file exists with mode `-rw-------`. Tell the user the key is in
~/selfhost/kener/.env, readable with `grep KENER_SECRET_KEY ~/selfhost/kener/.env`, and that
changing it signs everyone out and makes anything Kener encrypted under it unreadable, so it
belongs in their password manager today. On Windows those mode bits are advisory and the real
boundary is their own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/kener/compose.yml <<'EOF'
# Kener · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# deployment ......... https://kener.ing/docs/v4/setup/deployment
# environment ........ https://kener.ing/docs/v4/setup/environment-variables
# database ........... https://kener.ing/docs/v4/setup/database-setup
# image build ........ https://github.com/rajnandan1/kener/blob/v4.1.2/Dockerfile
#
# Two services, on the computer you are sitting at, every path relative to
# ~/selfhost/kener/ so one file works on macOS, Linux and Windows. Redis is not
# optional in v4: upstream calls it required for the queues, the cache and the
# scheduler, and its /data is a named volume because that image chowns its data
# directory to its own uid, which a Windows bind mount cannot allow. The
# database stays a bind mount and DATABASE_URL is unset, so upstream defaults
# to SQLite there, under the node user uid 1000, hence step 3's Linux-only
# chown. Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
container_name: kener-redis
restart: unless-stopped
volumes:
# Queue state only: monitors and results are in the SQLite file below.
- kener-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# No `ports:` at all: 6379 is reachable only from the other container.
kener:
image: ghcr.io/rajnandan1/kener:v4.1.2@sha256:239ab635b900b3fe0a4e22f5599f77525211d1b7a25b8fcf3d173936ddacc1cc
container_name: kener
restart: unless-stopped
env_file: ./.env
environment:
# Redis is on the compose network only, so this carries no credential.
REDIS_URL: redis://redis:6379
NODE_ENV: production
volumes:
# kener.sqlite.db: every monitor, incident, page, user and result.
- ./database:/app/database
ports:
# Loopback only: no other device on the wifi can reach 8179.
- "127.0.0.1:8179:3000"
depends_on:
redis:
condition: service_healthy
volumes:
kener-redis-data:
EOF
cd ~/selfhost/kener && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no
hostname to resolve, a certificate attests a public name and nothing here has one, and browsers
treat http://localhost as a secure context anyway. Nothing is published past loopback, so no
port needs closing.
8179 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, nobody on the
internet. For most apps that is a fair trade; for a status page it is the trade, because being
readable by somebody else is what this software exists to do. Confirm the binding:
```bash
grep -c '"127.0.0.1:' ~/selfhost/kener/compose.yml
```
Assert: that prints `1`, the one published-port line `- "127.0.0.1:8179:3000"`. Redis publishes
no host port. The checks still reach the internet: loopback governs what arrives, not what a
container can call.
## 7. Start and verify
Kener runs migrations and a seed on the way up, so the first boot writes a schema and a starter
page before answering. Use the loop, not a fixed sleep.
```bash
cd ~/selfhost/kener
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' 'http://localhost:8179/healthcheck?strict=1'); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8179/healthcheck; echo
curl -sSL http://localhost:8179/ | grep -c 'Service Status'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8179/api/v1/monitors
curl -sSL http://localhost:8179/account/signin | grep -c 'Create Admin Account'
```
Assert all five, and print what you received for each. The loop ends on `200`, and it asks
with `?strict=1` on purpose: upstream answers `200` from `/healthcheck` even when a dependency
is down, and only the strict form turns that into `503`. The plain call then prints
`{"status":"ok","db":true,"redis":true}`, and all three fields matter. The third prints a
number greater than `0`, because `Service Status` is the heading the seeded page renders. The
API call prints `401`, upstream's answer to a request with no bearer token. The last prints a
number greater than `0`, because with no users yet the sign-in page is a `Create Admin Account`
form. If any of the five misses, stop, run `docker compose logs --tail 40 kener`, then
`docker compose logs --tail 20 redis`: a Redis that never reports healthy holds the app in
`depends_on`. If `port is already allocated` came back, find what holds 8179
(`lsof -nP -iTCP:8179 -sTCP:LISTEN`, `ss -ltnp | grep 8179` on Linux,
`netstat -ano | findstr :8179` on Windows) and stop until the user frees it: 8179 is inside
`ORIGIN`. A running container is not success.
STOP: tell the user to open http://localhost:8179/account/signin, fill in a name, an email
address and a password to create the administrator account, and save that password in their
password manager. Do not continue until they confirm.
Once they confirm, prove the setup form is gone:
```bash
curl -sSL http://localhost:8179/account/signin | grep -c 'Create Admin Account'
curl -sSL http://localhost:8179/account/signin | grep -c 'Sign In'
```
Assert: the first prints `0`, the second a number greater than `0`. Upstream's signup action
refuses once a user exists, so that page is a login form from here on.
## 8. First backup and restore
One archive: the SQLite database, the environment file and the compose file. Redis is not in
it, because it holds only queue state that rebuilds itself.
```bash
cd ~/selfhost/kener
docker compose stop
tar -C ~/selfhost/kener -czf ~/selfhost/kener/backups/kener-$(date +%F).tar.gz database .env compose.yml
docker compose start
ls -lh ~/selfhost/kener/backups/
```
Assert: the archive exists and is non-empty. Print its size. The containers stop for a few
seconds on purpose, because a SQLite file copied mid-write is not a backup.
That archive sits on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB
stick, and copy it there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms the filename is listed there. If they have nowhere to
put it, say plainly that this install has no backup.
To restore: `cd ~/selfhost/kener`, `docker compose down`, `rm -rf database`, untar the archive
there, then `docker compose up -d`. `database/kener.sqlite.db` is their monitors, their
incidents and their account; `.env` is the key that decrypts what Kener stored under it.
## 9. Updating later
New versions are at https://github.com/rajnandan1/kener/releases, and the release tag is the
image tag. Take a backup first, then edit the image line in ~/selfhost/kener/compose.yml to the
new tag and its digest:
```bash
cd ~/selfhost/kener
docker compose pull
docker compose up -d
docker compose logs --tail 30 kener
```
Kener migrates on the way up, so watch that log until it settles, then re-run step 7's health
check and the `Service Status` grep before calling the update done.
## 10. What will probably go wrong
I rebooted, opened http://localhost:8179 out of habit, and got a connection error that read
like a lost database. It was not. Docker Desktop had not started with the session, so nothing
was listening on 8179 and no check had run since the reboot: `restart: unless-stopped` acts
only once the Docker daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/kener && docker compose up -d` before concluding anything broke. The quieter
version: uptime is worked out from the results that exist, so hours when this machine slept
leave no failed result and read as clean.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8179 to 0.0.0.0 and do not point `ORIGIN` at this machine's LAN address. That
publishes a page listing every URL the user monitors, plus its sign-in form, on every network
this machine joins.
- Do not set `DATABASE_URL` and do not add a Postgres or MySQL service. SQLite is why this is
one folder to copy.
- Do not configure SMTP, `RESEND_API_KEY`, or any alerting provider. Each is an account or key
somewhere else.compose.local.ymlthe services, pinned · local layout54 lines
# Kener · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# deployment ......... https://kener.ing/docs/v4/setup/deployment
# environment ........ https://kener.ing/docs/v4/setup/environment-variables
# database ........... https://kener.ing/docs/v4/setup/database-setup
# image build ........ https://github.com/rajnandan1/kener/blob/v4.1.2/Dockerfile
#
# Two services, on the computer you are sitting at, every path relative to
# ~/selfhost/kener/ so one file works on macOS, Linux and Windows. Redis is not
# optional in v4: upstream calls it required for the queues, the cache and the
# scheduler, and its /data is a named volume because that image chowns its data
# directory to its own uid, which a Windows bind mount cannot allow. The
# database stays a bind mount and DATABASE_URL is unset, so upstream defaults
# to SQLite there, under the node user uid 1000, hence step 3's Linux-only
# chown. Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
container_name: kener-redis
restart: unless-stopped
volumes:
# Queue state only: monitors and results are in the SQLite file below.
- kener-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# No `ports:` at all: 6379 is reachable only from the other container.
kener:
image: ghcr.io/rajnandan1/kener:v4.1.2@sha256:239ab635b900b3fe0a4e22f5599f77525211d1b7a25b8fcf3d173936ddacc1cc
container_name: kener
restart: unless-stopped
env_file: ./.env
environment:
# Redis is on the compose network only, so this carries no credential.
REDIS_URL: redis://redis:6379
NODE_ENV: production
volumes:
# kener.sqlite.db: every monitor, incident, page, user and result.
- ./database:/app/database
ports:
# Loopback only: no other device on the wifi can reach 8179.
- "127.0.0.1:8179:3000"
depends_on:
redis:
condition: service_healthy
volumes:
kener-redis-data:agent-readable mirror: /self-host/uptime.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned51 lines
# Kener · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# deployment ......... https://kener.ing/docs/v4/setup/deployment
# environment ........ https://kener.ing/docs/v4/setup/environment-variables
# database ........... https://kener.ing/docs/v4/setup/database-setup
# image build ........ https://github.com/rajnandan1/kener/blob/v4.1.2/Dockerfile
#
# Two services. Redis is not optional in v4: upstream's own compose file calls
# it required for the queues, the cache and the scheduler, and the environment
# reference marks REDIS_URL required, so a Kener with no Redis runs no checks.
# No database container: DATABASE_URL is left unset, so upstream defaults to
# SQLite at ./database/kener.sqlite.db, the mount below. The image runs as the
# node user, uid 1000, which is why step 2 hands database/ to 1000. Digests
# read from the registries on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
redis:
image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
container_name: kener-redis
restart: unless-stopped
volumes:
# Queue and scheduler state only: monitors, incidents and every result
# live in the SQLite file below. The image chowns this on first start.
- /srv/kener/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# No `ports:` at all: 6379 is reachable only from the other container.
kener:
image: ghcr.io/rajnandan1/kener:v4.1.2@sha256:239ab635b900b3fe0a4e22f5599f77525211d1b7a25b8fcf3d173936ddacc1cc
container_name: kener
restart: unless-stopped
env_file: /srv/kener/.env
environment:
# Redis is on the compose network only, so this carries no credential.
REDIS_URL: redis://redis:6379
NODE_ENV: production
volumes:
# kener.sqlite.db: every monitor, incident, page, user and result.
- /srv/kener/database:/app/database
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8179.
- "127.0.0.1:8179:3000"
depends_on:
redis:
condition: service_healthyCaddyfilethe hostname and TLS31 lines
# Kener · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://kener.ing/docs/v4/setup/deployment and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That same hostname is
# ORIGIN in .env: Kener hands ORIGIN to SvelteKit for the cross-site check on
# every form post, so if the two disagree the page loads and the sign-in fails.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
# The status page is public and the manage screens behind it are not,
# and both share a hostname. SAMEORIGIN protects the second, at the cost
# of the badge embeds Kener offers for other people's sites.
X-Frame-Options "SAMEORIGIN"
# Upstream reads the Origin header when the browser sends one and falls
# back to SameSite=Lax cookies when it does not, so this costs nothing.
Referrer-Policy "no-referrer"
-Server
}
# 8179 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8179
}install.shthe same install, no agent176 lines
#!/usr/bin/env bash
# Kener · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=status.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://kener.ing/docs/v4/setup/deployment
# https://kener.ing/docs/v4/setup/environment-variables
# https://kener.ing/docs/v4/setup/database-setup
# https://github.com/rajnandan1/kener/blob/v4.1.2/Dockerfile
#
# One secret is generated here, on this machine: KENER_SECRET_KEY, which signs
# the session tokens and encrypts stored credentials. It goes into
# /srv/kener/.env with mode 600 and is never printed.
#
# DOMAIN_HOST is also ORIGIN, the address SvelteKit compares every form post
# against. It takes no trailing slash, and a mismatch shows up as a sign-in that
# fails on a page that loads.
#
# This script cannot create the administrator account, because only a browser
# can. It stops with the setup form still open and tells you to go and claim it.
# Until you do, whoever loads the hostname first can.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/kener}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. status.example.com"
case "$DOMAIN_HOST" in
*/*) die "DOMAIN_HOST is a hostname, not a URL: no scheme and no trailing slash" ;;
esac
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; Kener plus Redis wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# Three owners, on purpose. The Kener image runs as the node user, uid 1000, so
# it cannot write a database directory owned by the login user. The redis image
# chowns its own data directory on first start, so that one stays with root.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/database"
sudo install -d -m 750 "$APP_DIR/redis"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the one secret, on the server -------------------------------
#
# Upstream documents `openssl rand -base64 32` for this key. Read it later with
# sudo grep KENER_SECRET_KEY /srv/kener/.env
# Changing it signs everyone out and makes anything encrypted under it
# unreadable, so put it in a password manager the day you run this.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
ORIGIN=https://${DOMAIN_HOST}
TZ=UTC
KENER_SECRET_KEY=$(openssl rand -base64 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-kener"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8179 nor 6379 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8179 and 6379 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# Kener runs its migrations and its seed on the way up, so the first boot writes
# the schema and a starter status page before it answers anything.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/healthcheck?strict=1"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthcheck?strict=1" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/healthcheck?strict=1 answered ${code:-nothing}. Check: docker compose logs --tail 40 kener"
# The plain endpoint answers 200 even when a dependency is down, so the body is
# the real assert: db and redis both have to be true.
curl -sS "https://${DOMAIN_HOST}/healthcheck" | grep -q '"status":"ok"' \
|| die "/healthcheck did not report status ok. Check: docker compose logs --tail 20 redis"
# The heading the seeded status page renders. Its absence means Caddy is
# reaching something other than Kener.
curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'Service Status' \
|| die "the page at https://${DOMAIN_HOST}/ does not carry the status page heading"
# The API must refuse a call carrying no bearer token.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/monitors" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."
# With no users in the database, the sign-in page is a setup form. Confirming it
# is there is the last assert; closing it is the first thing you do next.
curl -sSL "https://${DOMAIN_HOST}/account/signin" | grep -q 'Create Admin Account' \
|| die "https://${DOMAIN_HOST}/account/signin is not offering the setup form; a user may already exist"
# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped on purpose: a SQLite file copied mid-write is not a backup. Downtime
# is a few seconds. Redis is not in the archive, because it holds queue state
# that rebuilds itself.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/kener-${STAMP}.tar.gz" -C "$APP_DIR" database .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/kener-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
Kener is answering at https://${DOMAIN_HOST}
1. Do this now, before anything else: open
https://${DOMAIN_HOST}/account/signin
and create the administrator account. Until you do, that form is a
Create Admin Account form for whoever loads the hostname first.
Upstream refuses a second signup once one user exists, so claiming it
closes the window for good.
2. That status page is public. It has no login, and every monitor name
and description you add is on it. Open it in a private window and read
it as a stranger would before you add anything internal.
3. Your KENER_SECRET_KEY is in $APP_DIR/.env, mode 600. Read it with
sudo grep KENER_SECRET_KEY $APP_DIR/.env
and put it in your password manager. It was not printed here.
4. Keep one free external check pointed at https://${DOMAIN_HOST} from a
service you do not run. This box cannot tell you that this box is down.
5. First backup written to $APP_DIR/backups. It is on the same disk as
the data, which is not a backup. Copy it somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Uptime.com.
- One vantage point, and it cannot see itself die. Uptime.com runs every check from several of its own locations; this checks from wherever you put the container, and if that machine or its network is what fails, the page that would have said so is down with it. Run it somewhere other than the servers it watches, and keep one free external check pointed at it. This is the one place where paying nobody is not the right answer.
- Two containers, because Redis is not optional. Upstream's own compose file calls it required for the queues, the cache and the scheduler, so a Kener whose Redis is unhappy is a status page that has quietly stopped checking anything. The trap is that /healthcheck still answers 200 in that state and only says degraded in the body, so build your own monitoring of this monitor around ?strict=1.
- The status page is public, because that is what a status page is. There is no login on it, and the name and description of every monitor you add are published to anyone who loads the page, so an internal admin URL in a monitor is an internal admin URL on the open web. What is behind a login is the manage side: incidents, maintenance windows, users and the monitors themselves.
- You own the alerting path and it ships switched off. Every provider needs a webhook or a key from a service you sign up for, and mail needs an SMTP relay or a Resend key you supply. Send yourself a deliberate failure the day you wire one up, because an untested alert channel is silence with extra steps.
- No probes in other countries, no transaction checks that click through a signup flow, no SMS gateway and no subscriber notification list. Those four are most of what the paid tiers are actually selling.
Where this came from
“Redis is required for BullMQ queues, caching, and scheduler”
- Redis is required in Kener v4, not optional: the environment reference marks REDIS_URL as a required variable alongside KENER_SECRET_KEY and ORIGIN, which is why this install runs two containers rather than one. source
- DATABASE_URL defaults to SQLite at ./database/kener.sqlite.db when it is not set, so a self-hosted Kener needs no Postgres or MySQL container. source
- The published image runs as the non-root node user, exposes port 3000, and carries its own health check against /healthcheck, and the Knex migrations and seeds run at start-up. source
- /healthcheck answers 200 with a status of degraded when the database or Redis is down, and only ?strict=1 turns a failed dependency into a 503, so a bare 200 is not proof the checks are running. source
- On an install with no users the sign-in page is a Create Admin Account form, and upstream's signup action refuses once one user exists, so claiming the first account closes registration for good. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Uptime.com?
Not Uptime.com itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Kener. A public status page with the incidents, the maintenance windows and the monitors behind it, all managed in a browser rather than a config file. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 80 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Uptime.com?
Kener. A public status page with the incidents, the maintenance windows and the monitors behind it, all managed in a browser rather than a config file. The closest thing here to what an Uptime.com subscription is actually for: a status page you would show customers, with the monitoring behind it rather than bolted on. Monitors, incidents, maintenance windows and multiple pages are managed in a signed-in browser, so declaring an incident is a form rather than a commit, and the page a visitor sees carries the incident timeline that most self-hosted monitors leave out. What you take on is two containers instead of one, because Redis is not optional in v4, and a single vantage point where Uptime.com sells twelve. Also evaluated and ranked below it: Gatus, Uptime Kuma. The prompt on this page installs Kener only. Kener is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Uptime.com?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Kener itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Uptime.com Website Monitoring · 10 basic checks, $9/mo — $108 a year, a metered rate, not a whole bill.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Kener install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Kener on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Kener on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: A status page is for other people to read, and this one answers only at http://localhost:8179, so nobody you would send it to can open it; the checks also stop while the computer sleeps, and hours with no results read as clean rather than as a gap. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.