# Can I self-host AnyList?

**YES** — it's called KitchenOwl. ONE COMMAND setup · ~9 minutes to running · 1 GB RAM minimum · $1.25/mo you stop paying ($15/yr on the Complete (household) plan).

KitchenOwl authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/anylist-complete/

## Install prompt (Claude Code)

````text
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 KitchenOwl 0.7.10 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. Say this when you ask: the hostname becomes
`FRONT_URL`, and it is also the address every phone in the household types into the KitchenOwl
app to find this server, so changing it later means visiting every phone.

KitchenOwl needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64. Measure all four first:

```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. Stop on anything that is not `amd64` or `arm64`. If `dig +short` prints
nothing, print that and stop: Caddy cannot get a certificate for a name that does not resolve,
and failed attempts count against a rate limit you cannot see.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/kitchenowl /srv/kitchenowl/backups
sudo install -d -m 755 /srv/kitchenowl/data
ls -la /srv/kitchenowl
```

Assert: `ls -la` shows `backups` owned by the login user and `data` present. Everything
KitchenOwl keeps goes under `data`: the SQLite file the shopping lists, recipes, meal plans and
expenses live in, and an `upload` directory of item and recipe photos it creates on the way up.
The container process runs as root and writes there itself, so leave the ownership alone and
expect to read that directory back with sudo after step 7.

## 3. Secrets

One secret: the JWT signing key. Upstream's own sample compose file sets it to a fixed
placeholder string printed on the same documentation page, so an install that leaves the
default alone can have its session tokens minted by anyone who read that page. Hex, not base64,
because it travels in a container environment variable. Generate it here, print it never, and
keep it out of your summary and every log line.

```bash
umask 077
cat > /srv/kitchenowl/.env <<EOF
FRONT_URL=https://<DOMAIN>
JWT_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/kitchenowl/.env
umask 022
ls -l /srv/kitchenowl/.env
```

Assert: the file exists with mode `-rw-------` and the login user's name twice. Replace
`<DOMAIN>` on the first line with the real hostname before running the block. `FRONT_URL` is
the origin upstream documents for the CORS header, and it has to match the address the app is
opened at exactly, scheme included and no trailing slash. Tell the user one thing about the
key: it is not a password they ever type, but rotating it later signs every phone and browser
out.

## 4. compose.yml

```bash
cat > /srv/kitchenowl/compose.yml <<'EOF'
# KitchenOwl · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting ....... https://docs.kitchenowl.org/v0.7.10/self-hosting/
#   variable reference . https://docs.kitchenowl.org/v0.7.10/self-hosting/advanced/
#   reverse proxy ...... https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/
#   image .............. https://github.com/TomBursch/kitchenowl/blob/v0.7.10/Dockerfile
#
# One service. Upstream publishes two shapes, a split front-and-back pair and an
# all-in-one image, and this file takes the all-in-one: the same container serves
# the web app on 8080 and answers /api on that same port, so there is nothing to
# route between two containers. The database driver defaults to sqlite and the
# file lands in /data next to the uploaded photos, so no database container runs
# here and there is nothing to dump. The image declares its own HEALTHCHECK
# against the /api/health route, so none is repeated below. Tag and digest read
# from Docker Hub on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  kitchenowl:
    image: tombursch/kitchenowl:v0.7.10@sha256:bd821a41b8cb27fd7fcf429acd1fc67e9f889485a2cd1193d68c2d804a8e1bef
    container_name: kitchenowl
    restart: unless-stopped
    # FRONT_URL and the signing key come from /srv/kitchenowl/.env, mode 600 and
    # owned by the login user. The image carries a signing-key default that
    # upstream prints in its own sample, so this file is only safe with that
    # file in place.
    env_file: /srv/kitchenowl/.env
    environment:
      # Nobody can create an account from the sign-in screen. The first account
      # invites the rest of the household instead.
      OPEN_REGISTRATION: "false"
      # Onboarding stays available until one account exists, then the server
      # closes it on its own: the endpoint counts users and refuses at one.
      DISABLE_ONBOARDING: "false"
    volumes:
      - /srv/kitchenowl/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8167.
      - "127.0.0.1:8167:8080"
EOF
cd /srv/kitchenowl && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount. Upstream
documents a PostgreSQL driver and a split front-and-back deployment as well, and this install
takes neither: SQLite inside the one image is the default and it is one less thing to operate.

## 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 takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-kitchenowl
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# KitchenOwl · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/ 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 hostname is also
# FRONT_URL in .env, and upstream asks for an exact match including the scheme,
# so the two have to stay the same string.

<DOMAIN> {
	# The interface is a compiled web bundle and the API answers JSON. Caddy
	# leaves the already-compressed recipe and item photos alone.
	encode zstd gzip

	# Upstream calls Strict-Transport-Security the header this app needs, on
	# the grounds that a plain-http answer on this hostname is enough to hand
	# an attacker a session. The frame and sniff headers come from the same
	# page.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# The app opens a websocket back to this hostname and upstream warns that
	# some requests get noticeably slower without one. Caddy upgrades the
	# connection here with no extra directive.
	#
	# 8167 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:8167
}
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-kitchenowl, reload, and report what it objected to. Caddy requests
the certificate on the first request to the hostname and renews it on its own. Nothing to
schedule, and no certificate path is written down anywhere.

## 6. Firewall

Two ports open, both Caddy's, and both idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp
is HTTP/3, and 8167 stays closed because compose binds it to 127.0.0.1. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
mentioning 8167.

## 7. Start and verify

The container migrates its database and imports a default item list on the way up, so the first
start is slower than the ones after it. Understand what is open while it runs: with no account
in the database, the onboarding endpoint hands the owner account to whoever posts to it first,
and it is on the public internet from the second the container answers. The gap between this
block starting and the user creating their account is the whole risk in this install.

```bash
cd /srv/kitchenowl
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>/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V
curl -sS https://<DOMAIN>/api/onboarding
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
```

Assert all four, and print what you received for each: the loop ends on `200`; the health JSON
has a `msg` field reading `OK` and no `open_registration` field, which is how the server reports
that public signups are off; the onboarding call answers with `onboarding` set to `true`,
meaning the database has no users yet; the last prints `<title>KitchenOwl</title>`. If any of the
four misses, stop, run `docker compose logs --tail 40 kitchenowl`, and name the likely earlier
step: a `502` instead of `200` means Caddy is reaching nothing on 8167, and a container
restarting in a loop points at step 2 or at an empty `.env` from step 3. A running container is
not success.

The first screen at https://<DOMAIN> shows the heading `Let's create a user` above a `Start`
button, with a `Switch server` link under it. That heading is the onboarding form, and it
appears because no account exists.

STOP: tell the user to open https://<DOMAIN> now, press `Start`, and create their account
with a username, a name and a password they choose themselves. Wait.
Do not continue until they confirm. That first account is the owner: the server creates
it with admin rights and then refuses to create a second one this way.

Once they confirm, prove the door is shut:

```bash
curl -sS https://<DOMAIN>/api/onboarding
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/auth/signup
```

Assert both: the first answers with `onboarding` set to `false`, and the second prints `404`.
The `404` is the security assert in this block: with public registration off, the server does
not publish a signup route at all, so a missing one is the correct answer rather than a routing
fault. Both must pass before you report success. If `onboarding` still reads `true`, the account
was not created and the owner slot is unclaimed, so stop and say so plainly.

Then hand the user the step this install exists for: each phone installs KitchenOwl from its
app store, picks the use-your-own-server option, and types https://<DOMAIN> into it once.
Invited household members do the same on their phones.

## 8. First backup and restore

Two artifacts. The data archive holds the SQLite database and the photo uploads; the config
archive holds the files that rebuild the service around them. Stop the container for the
archive: a SQLite file copied while it is being written is not a backup. Downtime is a few
seconds.

```bash
cd /srv/kitchenowl
docker compose stop
sudo tar -czf /srv/kitchenowl/backups/kitchenowl-data-$(date +%F).tar.gz -C /srv/kitchenowl data
docker compose start
sudo tar -czf /srv/kitchenowl/backups/kitchenowl-config-$(date +%F).tar.gz -C /srv/kitchenowl compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/kitchenowl/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. A backup on the same disk as
the data is not a backup, so run this one from the user's machine, not the server:

```bash
mkdir -p ~/backups/kitchenowl
scp vps:/srv/kitchenowl/backups/* ~/backups/kitchenowl/
```

To restore: `docker compose down`, `sudo rm -rf /srv/kitchenowl/data`,
`sudo tar -xzf /srv/kitchenowl/backups/kitchenowl-data-<date>.tar.gz -C /srv/kitchenowl`, untar
the config archive into /srv/kitchenowl so compose.yml and .env are back, then
`docker compose up -d`. Tell the user the fact that matters at 2am: the signing key is in
`.env` and not in the database, so restoring the data without that file signs every phone in
the house out, and restoring both signs nobody out.

## 9. Updating later

New versions are listed at https://github.com/TomBursch/kitchenowl/releases. Take both backup
artifacts first, then edit the image line in /srv/kitchenowl/compose.yml to the new tag and its
digest:

```bash
cd /srv/kitchenowl
docker compose pull
docker compose up -d
docker compose logs --tail 30 kitchenowl
```

KitchenOwl runs its own database migrations on the way up, so watch that log until it settles,
then re-run the health check from step 7 before calling the update done. The phone apps carry a
minimum server version of their own, so an app that stops connecting after a store update is a
server that has been left behind rather than a broken phone.

## 10. What will probably go wrong

The first browser load looks like a failed install. I ran the step 7 checks, got `200`, got the
title back from curl, opened the hostname in a browser and sat looking at an empty off-white
page long enough to start reading logs. Nothing was wrong: the interface is a compiled bundle
of several megabytes that the browser downloads before it paints anything, and the page served
in the meantime carries a background colour and no content. Give it a full minute, reload once,
and only then start checking whether Caddy is reaching 8167.

## 11. Out of scope

- Do not switch the database driver to PostgreSQL. SQLite in the one image is the default and
  the choice here, and moving between the two is a restore, not an edit.
- Do not configure SMTP. KitchenOwl runs without it; the cost is password-reset mail, and the
  owner can hand out household invitations from inside the app instead.
- Do not set `OPEN_REGISTRATION` or `DISABLE_USERNAME_PASSWORD_LOGIN`, and do not configure
  OIDC, Google or Apple sign-in. Public signups on a household grocery list are a spam surface,
  and the three identity options need an account somewhere else.
- Do not set `KITCHENOWL_MCP_ENABLED` or `COLLECT_METRICS`. The first publishes an agent
  endpoint and the second a metrics endpoint whose default basic-auth password is printed in
  upstream's documentation.
````

## Chat fallback

````text
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 KitchenOwl 0.7.10 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 this before step 1. `<DOMAIN>` becomes `FRONT_URL`, and it is also the address every phone
in your household types into the KitchenOwl app to find this server, so changing it later means
visiting every phone. Pick the hostname you intend to keep.

## 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. An architecture that is
not `amd64` or `arm64` is a stop, because upstream builds the image for those two only. Under
1024 MB available is also a stop: the backend is a Python service that loads an English
part-of-speech tagger for its ingredient parsing, and a box that cannot spare the memory meets
the OOM killer during the first start rather than later.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/kitchenowl /srv/kitchenowl/backups
sudo install -d -m 755 /srv/kitchenowl/data
ls -la /srv/kitchenowl
```

You should see: `backups` owned by you, and `data` at mode `drwxr-xr-x`.

If you do not: nothing here needs fixing by hand. Everything KitchenOwl keeps goes under `data`:
the SQLite file the shopping lists, recipes, meal plans and expenses live in, and an `upload`
directory of item and recipe photos it creates the first time it starts. The container process
runs as root and writes there itself, so leave the ownership alone, and expect the backup
command in step 8 to need `sudo` once the container has been up.

## 3. Secrets

One secret: the JWT signing key. Upstream's own sample compose file sets it to a fixed
placeholder string printed on the same documentation page, so an install that leaves the
default alone can have its session tokens minted by anyone who read that page. Replace
`<DOMAIN>` on the first line with your real hostname before you paste.

```bash
umask 077
cat > /srv/kitchenowl/.env <<EOF
FRONT_URL=https://<DOMAIN>
JWT_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/kitchenowl/.env
umask 022
ls -l /srv/kitchenowl/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens when
the lines are pasted separately into different shells. Run `chmod 600 /srv/kitchenowl/.env` and
carry on. If the file already existed from an earlier attempt, this block has overwritten the
signing key, which is harmless before anybody has signed in and a mass logout afterwards: every
phone and browser has to sign in again, and nothing else is lost.

Do not paste that file, the key, or any command output containing it into this chat window. The
value is yours and no third party needs a copy. `FRONT_URL` is the origin upstream documents for
the CORS header, and it has to match the address you open the app at exactly, scheme included
and no trailing slash.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/kitchenowl/compose.yml <<'EOF'
# KitchenOwl · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting ....... https://docs.kitchenowl.org/v0.7.10/self-hosting/
#   variable reference . https://docs.kitchenowl.org/v0.7.10/self-hosting/advanced/
#   reverse proxy ...... https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/
#   image .............. https://github.com/TomBursch/kitchenowl/blob/v0.7.10/Dockerfile
#
# One service. Upstream publishes two shapes, a split front-and-back pair and an
# all-in-one image, and this file takes the all-in-one: the same container serves
# the web app on 8080 and answers /api on that same port, so there is nothing to
# route between two containers. The database driver defaults to sqlite and the
# file lands in /data next to the uploaded photos, so no database container runs
# here and there is nothing to dump. The image declares its own HEALTHCHECK
# against the /api/health route, so none is repeated below. Tag and digest read
# from Docker Hub on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  kitchenowl:
    image: tombursch/kitchenowl:v0.7.10@sha256:bd821a41b8cb27fd7fcf429acd1fc67e9f889485a2cd1193d68c2d804a8e1bef
    container_name: kitchenowl
    restart: unless-stopped
    # FRONT_URL and the signing key come from /srv/kitchenowl/.env, mode 600 and
    # owned by the login user. The image carries a signing-key default that
    # upstream prints in its own sample, so this file is only safe with that
    # file in place.
    env_file: /srv/kitchenowl/.env
    environment:
      # Nobody can create an account from the sign-in screen. The first account
      # invites the rest of the household instead.
      OPEN_REGISTRATION: "false"
      # Onboarding stays available until one account exists, then the server
      # closes it on its own: the endpoint counts users and refuses at one.
      DISABLE_ONBOARDING: "false"
    volumes:
      - /srv/kitchenowl/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8167.
      - "127.0.0.1:8167:8080"
EOF
cd /srv/kitchenowl && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/kitchenowl/.env not found` means step 3 did not write the file, or
you are not in /srv/kitchenowl. `services must be a mapping` means the indentation was lost
between the page and your terminal: run `rm /srv/kitchenowl/compose.yml` and paste again in one
go. Upstream documents a PostgreSQL driver and a split front-and-back deployment as well, and
this file takes neither. SQLite inside the one image is the default, and it is one less thing to
run, watch and dump.

## 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-kitchenowl
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# KitchenOwl · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/ 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 hostname is also
# FRONT_URL in .env, and upstream asks for an exact match including the scheme,
# so the two have to stay the same string.

<DOMAIN> {
	# The interface is a compiled web bundle and the API answers JSON. Caddy
	# leaves the already-compressed recipe and item photos alone.
	encode zstd gzip

	# Upstream calls Strict-Transport-Security the header this app needs, on
	# the grounds that a plain-http answer on this hostname is enough to hand
	# an attacker a session. The frame and sniff headers come from the same
	# page.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# The app opens a websocket back to this hostname and upstream warns that
	# some requests get noticeably slower without one. Caddy upgrades the
	# connection here with no extra directive.
	#
	# 8167 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:8167
}
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-kitchenowl /etc/caddy/Caddyfile`, reload,
and paste again. The usual cause is a `<DOMAIN>` left literal 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 and no certificate path to write down.

## 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 `8167`.

If you do not: delete anything for `8167` with `sudo ufw delete allow 8167`. That port is bound
to 127.0.0.1 by the compose file, so a firewall rule for it opens a door that leads nowhere and
confuses the next person who reads the output. 80/tcp is there to redirect to HTTPS and answer
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

The container migrates its database and imports a default item list on the way up, so the first
start is slower than the ones after it. Understand what is open while it runs: with no account
in the database, the onboarding endpoint hands the owner account to whoever posts to it first,
and it is on the public internet from the second the container answers. Do not start this block
and walk away.

```bash
cd /srv/kitchenowl
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>/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V
curl -sS https://<DOMAIN>/api/onboarding
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
```

You should see, in order: the loop reaching `200`, a small JSON object whose `msg` field reads
`OK` and which has no `open_registration` field, then a second object with `onboarding` set to
`true`, then `<title>KitchenOwl</title>`.

If you do not: the loop can legitimately take a minute or two on a small box, because the
container runs its migrations and imports a default item list before it answers anything. Give
it all thirty attempts before touching anything. A `502` that never clears means Caddy is
reaching nothing on 8167: check `docker compose ps`. A container restarting in a loop usually
means step 3 wrote nothing, so the signing key never reached it. The missing `open_registration`
field is not an error: the server only prints that key when public signups are on, so its
absence is the report that they are off.

Now claim the owner account, which is the part of this install with real security meaning. Open
https://<DOMAIN> in a browser. The first screen shows the heading `Let's create a user` above a
`Start` button, with a `Switch server` link under it. Press `Start` and create your account with
a username, a name and a password you choose yourself, and put that password in your password
manager. Do not type it into this chat window.

That first account is the owner: the server creates it with admin rights, and once it exists the
server refuses to create a second one this way. Everyone else in the household joins by
invitation from inside the app.

Then prove the door is shut:

```bash
curl -sS https://<DOMAIN>/api/onboarding
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/auth/signup
```

You should see: an object with `onboarding` set to `false`, then `404`.

If you do not: a first line still reading `true` means the account was not created and the owner
slot on your public hostname is still unclaimed by you. Go back to the browser and finish it
before anything else, because this is the one window in the install where a stranger who guessed
your hostname could take the admin account. The `404` on the second line is the assert with
teeth: with public registration off, the server does not publish a signup route at all, so its
absence is the correct answer rather than a routing fault. A `200` there would mean signups are
open, which is a stop.

Last, the step this install exists for: on each phone, install KitchenOwl from its app store,
choose the option to use your own server, and give it https://<DOMAIN>. Anyone you invite from
inside the app does the same on their phone.

## 8. First backup and restore

Two artifacts. The data archive holds the SQLite database and the photo uploads; the config
archive holds the files that rebuild the service around them. The container stops for the data
archive, because a SQLite file copied while it is being written is not a backup.

```bash
cd /srv/kitchenowl
docker compose stop
sudo tar -czf /srv/kitchenowl/backups/kitchenowl-data-$(date +%F).tar.gz -C /srv/kitchenowl data
docker compose start
sudo tar -czf /srv/kitchenowl/backups/kitchenowl-config-$(date +%F).tar.gz -C /srv/kitchenowl compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/kitchenowl/backups/
```

You should see: two files, the data archive a few hundred kilobytes on a fresh install and the
config archive a couple of kilobytes. The service is down for about five seconds.

If you do not: `tar: data: Cannot open: Permission denied` means you dropped the `sudo`. The
data directory is written by a container running as root, which step 2 warned about. A data
archive of about 45 bytes is an empty tar, which means the path was wrong.

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/kitchenowl
scp vps:/srv/kitchenowl/backups/* ~/backups/kitchenowl/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/kitchenowl/`.

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 `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty shopping list:

```bash
cd /srv/kitchenowl
docker compose down
sudo rm -rf /srv/kitchenowl/data
sudo tar -xzf /srv/kitchenowl/backups/kitchenowl-data-$(date +%F).tar.gz -C /srv/kitchenowl
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/onboarding
```

You should see: `onboarding` set to `false`, which means your owner account came back with the
database, and your browser session still signed in when you reload the page.

If you do not: `onboarding` reading `true` after a restore is the worst answer here. It means
the archive did not contain the database, so the server thinks it has no users and is offering
the owner slot to the internet again. Stop, put the container down, and check that the archive
was made with `-C /srv/kitchenowl data` rather than from inside the directory. Being signed out
while onboarding reads `false` is a milder problem: the signing key lives in `.env` rather than
in the database, so a restore that skipped the config archive invalidates every session and
everyone signs in again.

## 9. Updating later

New versions are listed at https://github.com/TomBursch/kitchenowl/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/kitchenowl/compose.yml to the new tag and
its digest.

```bash
cd /srv/kitchenowl
docker compose pull
docker compose up -d
docker compose logs --tail 30 kitchenowl
```

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. Then re-run the
health check from step 7 before you call the update done. One thing worth knowing before you
delay an update for months: the phone apps carry a minimum server version of their own, so an
app that stops connecting after a store update is a server that has been left behind rather than
a broken phone.

## 10. What will probably go wrong

The first browser load looks like a failed install. I ran the step 7 checks, got `200`, got the
title back from curl, opened the hostname in a browser and sat looking at an empty off-white
page long enough to start reading logs. Nothing was wrong: the interface is a compiled bundle of
several megabytes that the browser downloads before it paints anything, and the page served in
the meantime carries a background colour and no content. Give it a full minute, reload once, and
only then start checking whether Caddy is reaching 8167.

## 11. Out of scope

- Do not switch the database driver to PostgreSQL. SQLite in the one image is the default and
  the choice here, and moving between the two is a restore, not an edit.
- Do not configure SMTP. KitchenOwl runs without it; the cost is password-reset mail, and the
  owner can hand out household invitations from inside the app instead.
- Do not set `OPEN_REGISTRATION` or `DISABLE_USERNAME_PASSWORD_LOGIN`, and do not configure
  OIDC, Google or Apple sign-in. Public signups on a household grocery list are a spam surface,
  and the three identity options need an account somewhere else.
- Do not set `KITCHENOWL_MCP_ENABLED` or `COLLECT_METRICS`. The first publishes an agent
  endpoint and the second a metrics endpoint whose default basic-auth password is printed in
  upstream's documentation.
````

## Local install prompt (your own computer, no server)

````text
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 KitchenOwl 0.7.10 under ~/selfhost/kitchenowl, answering at http://localhost:8167.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
KitchenOwl earns its keep when the phone in the supermarket ticks off the item the laptop at
home added ten minutes ago. Here there is one address, http://localhost:8167, and it means
"this computer" wherever it is typed, so the phone in their pocket cannot open it and the app
on it has nothing to point at. They get a private grocery list on one desk.

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. KitchenOwl needs 1024 MB of RAM available
and 5 GB free on the home disk, and the image publishes amd64 and arm64. Every branch prints
free memory, so one floor covers all three; on macOS and Windows it is the host's, and Docker
Desktop takes its allocation out of that. Under either floor, print both numbers and stop.

## 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/kitchenowl/data ~/selfhost/kitchenowl/backups
ls -la ~/selfhost/kitchenowl
```

Assert: `ls -la` shows `data` and `backups`, both owned by the user. Everything KitchenOwl keeps
goes under `data`: the SQLite file holding the shopping lists, recipes, meal plans and expenses,
and an `upload` directory of photos. The container runs as root and writes there itself, so
there is no ownership fix on any of the three systems and no named volume hiding the files.

## 4. Secrets

One secret: the JWT signing key. Upstream's sample compose file sets it to a fixed placeholder
printed on the same documentation page, so an install that leaves the default alone can have
its session tokens minted by anyone who read that page. Generate it here, print it never, and
keep it out of your summary and every log line.

```bash
umask 077
cat > ~/selfhost/kitchenowl/.env <<EOF
FRONT_URL=http://localhost:8167
JWT_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/kitchenowl/.env
umask 022
ls -l ~/selfhost/kitchenowl/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. `FRONT_URL` is the origin upstream documents for the CORS header and
has to match the address the app is opened at exactly, scheme and port included.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/kitchenowl/compose.yml <<'EOF'
# KitchenOwl · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   self-hosting ....... https://docs.kitchenowl.org/v0.7.10/self-hosting/
#   variable reference . https://docs.kitchenowl.org/v0.7.10/self-hosting/advanced/
#   image .............. https://github.com/TomBursch/kitchenowl/blob/v0.7.10/Dockerfile
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/kitchenowl/, which lets one file work on macOS, Linux and Windows
# and keeps the shopping lists a folder you can open in Finder or Explorer. The
# container process runs as root and writes into /data itself, so the relative
# bind mount needs no named volume and no ownership fix on any of the three.
# The database driver defaults to sqlite and the file lands in ./data next to
# the uploaded photos. The image declares its own HEALTHCHECK against the
# /api/health route. FRONT_URL is http://localhost:8167, which is an address
# this computer answers and no other device does. Digest read from Docker Hub
# on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  kitchenowl:
    image: tombursch/kitchenowl:v0.7.10@sha256:bd821a41b8cb27fd7fcf429acd1fc67e9f889485a2cd1193d68c2d804a8e1bef
    container_name: kitchenowl
    restart: unless-stopped
    # FRONT_URL and the signing key come from ./.env, mode 600. The image
    # carries a signing-key default that upstream prints in its own sample, so
    # this file is only safe with that file in place.
    env_file: ./.env
    environment:
      # Nobody can create an account from the sign-in screen.
      OPEN_REGISTRATION: "false"
      # Onboarding stays available until one account exists, then the server
      # closes it on its own: the endpoint counts users and refuses at one.
      DISABLE_ONBOARDING: "false"
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8167.
      - "127.0.0.1:8167:8080"
EOF
cd ~/selfhost/kitchenowl && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount. There is no
database container: SQLite rides inside the same image, and it is the driver upstream defaults
to.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8167 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. For a grocery list built around a household that is the trade,
and it is the point of this path rather than a fault in it.

```bash
grep -c '"127.0.0.1:' ~/selfhost/kitchenowl/compose.yml
```

Assert: that prints `1`. One published port in the file, and it is the loopback one.

## 7. Start and verify

The container migrates its database and imports a default item list on the way up, so the first
start is slower than the ones after it. Nothing here is reachable from another machine, but
until an account exists the onboarding endpoint hands the owner account to whoever posts to it,
so finish this block in one sitting.

```bash
cd ~/selfhost/kitchenowl
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:8167/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8167/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V
curl -sS http://localhost:8167/api/onboarding
curl -sS http://localhost:8167/ | grep -o '<title>[^<]*</title>'
```

Assert all four, printing what you got for each: the loop ends on `200`; the health JSON has a
`msg` field reading `OK` and no `open_registration` field, which is how the server reports that
public signups are off; the onboarding call answers with `onboarding` set to `true`; the last
prints `<title>KitchenOwl</title>`. If any misses, stop, run
`docker compose logs --tail 40 kitchenowl`, and name the cause: a container still working
through migrations wants more time, one restarting in a loop points at an empty `.env` from
step 4, and `port is already allocated` means something else here already holds 8167. A running
container is not success.

The first screen at http://localhost:8167 shows the heading `Let's create a user` above a
`Start` button, with a `Switch server` link under it. It shows because no account exists.

STOP: tell the user to open http://localhost:8167 now, press `Start`, and create their
account with a username, a name and a password they choose themselves. Wait.
Do not continue until they confirm. That first account is the owner: the server creates
it with admin rights and then refuses to create a second one this way.

Once they confirm, prove the door is shut:

```bash
curl -sS http://localhost:8167/api/onboarding
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8167/api/auth/signup
```

Assert both: the first answers with `onboarding` set to `false`, and the second prints `404`.
With public registration off the server publishes no signup route at all, so a missing one is
the correct answer rather than a routing fault. If `onboarding` still reads `true`, the account
was not created and the owner slot is unclaimed, so stop and say so plainly.

## 8. First backup and restore

Two artifacts: a data archive with the SQLite database and the photo uploads, and a config
archive with the two files that rebuild the service around them. The container stops for the
first, because a SQLite file copied while it is being written is not a backup.

```bash
cd ~/selfhost/kitchenowl
docker compose stop
tar -C ~/selfhost/kitchenowl -czf ~/selfhost/kitchenowl/backups/kitchenowl-data-$(date +%F).tar.gz data
docker compose start
tar -C ~/selfhost/kitchenowl -czf ~/selfhost/kitchenowl/backups/kitchenowl-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/kitchenowl/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Downtime is seconds.

Both archives sit on the same disk as the data, which is not a backup, 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 both there with `cp`. In Git Bash a Windows drive is written
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are listed there. If
they have neither, say plainly that this install has no backup.

To restore: `cd ~/selfhost/kitchenowl`, `docker compose down`, delete `data`, unpack the archive
in its place with `tar -xzf backups/kitchenowl-data-<date>.tar.gz -C ~/selfhost/kitchenowl`,
untar the config archive the same way, then `docker compose up -d`. Tell the user what matters
at 2am: the signing key is in `.env` rather than the database, so restoring the data without
that file signs every open tab out, and restoring both signs nobody out.

## 9. Updating later

New versions are listed at https://github.com/TomBursch/kitchenowl/releases. Take both backups
first, then edit the image line in ~/selfhost/kitchenowl/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/kitchenowl
docker compose pull
docker compose up -d
docker compose logs --tail 30 kitchenowl
```

KitchenOwl migrates its own database on the way up, so watch that log until it settles, then
re-run step 7's health check before calling this done.

## 10. What will probably go wrong

I closed the laptop lid with a half-written shopping list on screen, opened it an hour later,
and the page refused to take a new item while still showing the old ones. It was not a lost
database. Docker Desktop suspends its virtual machine when the computer sleeps, the live
connection the page holds open dies with it, and the tab goes on rendering what it already had.
Reload the tab first; if it still hangs, run `cd ~/selfhost/kitchenowl && docker compose ps` and
give the container a minute.

## 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 8167 to 0.0.0.0 so the phone in the kitchen can reach it. That puts a household
  grocery list, its photos and its expense records on every network this machine joins.
- Do not set `OPEN_REGISTRATION`, and do not configure OIDC, Google or Apple sign-in. This
  install has one account on one computer.
- Do not switch the database driver to PostgreSQL, configure SMTP, or set
  `KITCHENOWL_MCP_ENABLED`. This install is one container with one file of data.
````

## docker-compose.yml

```yaml
# KitchenOwl · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   self-hosting ....... https://docs.kitchenowl.org/v0.7.10/self-hosting/
#   variable reference . https://docs.kitchenowl.org/v0.7.10/self-hosting/advanced/
#   reverse proxy ...... https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/
#   image .............. https://github.com/TomBursch/kitchenowl/blob/v0.7.10/Dockerfile
#
# One service. Upstream publishes two shapes, a split front-and-back pair and an
# all-in-one image, and this file takes the all-in-one: the same container serves
# the web app on 8080 and answers /api on that same port, so there is nothing to
# route between two containers. The database driver defaults to sqlite and the
# file lands in /data next to the uploaded photos, so no database container runs
# here and there is nothing to dump. The image declares its own HEALTHCHECK
# against the /api/health route, so none is repeated below. Tag and digest read
# from Docker Hub on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  kitchenowl:
    image: tombursch/kitchenowl:v0.7.10@sha256:bd821a41b8cb27fd7fcf429acd1fc67e9f889485a2cd1193d68c2d804a8e1bef
    container_name: kitchenowl
    restart: unless-stopped
    # FRONT_URL and the signing key come from /srv/kitchenowl/.env, mode 600 and
    # owned by the login user. The image carries a signing-key default that
    # upstream prints in its own sample, so this file is only safe with that
    # file in place.
    env_file: /srv/kitchenowl/.env
    environment:
      # Nobody can create an account from the sign-in screen. The first account
      # invites the rest of the household instead.
      OPEN_REGISTRATION: "false"
      # Onboarding stays available until one account exists, then the server
      # closes it on its own: the endpoint counts users and refuses at one.
      DISABLE_ONBOARDING: "false"
    volumes:
      - /srv/kitchenowl/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8167.
      - "127.0.0.1:8167:8080"
```

## compose.local.yml

```yaml
# KitchenOwl · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   self-hosting ....... https://docs.kitchenowl.org/v0.7.10/self-hosting/
#   variable reference . https://docs.kitchenowl.org/v0.7.10/self-hosting/advanced/
#   image .............. https://github.com/TomBursch/kitchenowl/blob/v0.7.10/Dockerfile
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/kitchenowl/, which lets one file work on macOS, Linux and Windows
# and keeps the shopping lists a folder you can open in Finder or Explorer. The
# container process runs as root and writes into /data itself, so the relative
# bind mount needs no named volume and no ownership fix on any of the three.
# The database driver defaults to sqlite and the file lands in ./data next to
# the uploaded photos. The image declares its own HEALTHCHECK against the
# /api/health route. FRONT_URL is http://localhost:8167, which is an address
# this computer answers and no other device does. Digest read from Docker Hub
# on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  kitchenowl:
    image: tombursch/kitchenowl:v0.7.10@sha256:bd821a41b8cb27fd7fcf429acd1fc67e9f889485a2cd1193d68c2d804a8e1bef
    container_name: kitchenowl
    restart: unless-stopped
    # FRONT_URL and the signing key come from ./.env, mode 600. The image
    # carries a signing-key default that upstream prints in its own sample, so
    # this file is only safe with that file in place.
    env_file: ./.env
    environment:
      # Nobody can create an account from the sign-in screen.
      OPEN_REGISTRATION: "false"
      # Onboarding stays available until one account exists, then the server
      # closes it on its own: the endpoint counts users and refuses at one.
      DISABLE_ONBOARDING: "false"
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8167.
      - "127.0.0.1:8167:8080"
```

## Caddyfile

```text
# KitchenOwl · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/ 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 hostname is also
# FRONT_URL in .env, and upstream asks for an exact match including the scheme,
# so the two have to stay the same string.

<DOMAIN> {
	# The interface is a compiled web bundle and the API answers JSON. Caddy
	# leaves the already-compressed recipe and item photos alone.
	encode zstd gzip

	# Upstream calls Strict-Transport-Security the header this app needs, on
	# the grounds that a plain-http answer on this hostname is enough to hand
	# an attacker a session. The frame and sniff headers come from the same
	# page.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# The app opens a websocket back to this hostname and upstream warns that
	# some requests get noticeably slower without one. Caddy upgrades the
	# connection here with no extra directive.
	#
	# 8167 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:8167
}
```

## install.sh

```bash
#!/usr/bin/env bash
# KitchenOwl · 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=kitchen.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.kitchenowl.org/v0.7.10/self-hosting/
#   https://docs.kitchenowl.org/v0.7.10/self-hosting/advanced/
#   https://docs.kitchenowl.org/v0.7.10/self-hosting/reverse-proxy/
#   https://github.com/TomBursch/kitchenowl/blob/v0.7.10/Dockerfile
#
# One secret is generated here, on this machine: the JWT signing key. Upstream's
# own sample sets it to a fixed placeholder printed in its documentation, so an
# install that keeps the default can have its session tokens minted by a
# stranger. It goes into /srv/kitchenowl/.env with mode 600 and is never printed.
#
# This script cannot open a browser, so it cannot claim the owner account for
# you. It finishes with that account still unclaimed and says so loudly. Claim
# it the minute this returns: the first person to complete the onboarding form
# on your hostname becomes the admin, and that is the whole risk in this install.
#
# DOMAIN_HOST becomes FRONT_URL, the address every phone in the household types
# into the KitchenOwl app. Changing it later means visiting every phone.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/kitchenowl}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
HEALTH_PATH="/api/health/8M4F88S8ooi4sMbLBfkkV7ctWwgibW6V"

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. kitchen.example.com"
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"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || [ "$arch" = "arm64" ] || die "architecture ${arch} is not built by upstream; KitchenOwl needs amd64 or arm64"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install 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 ----------------------------------------------------
#
# data/ is left alone on purpose: the container process runs as root and writes
# the SQLite file and the upload directory there itself.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 "$APP_DIR/data"
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 -------------------------------
#
# Hex rather than base64: it travels in a container environment variable. Read
# it later, if you ever need to, with
#   sudo grep JWT_SECRET_KEY /srv/kitchenowl/.env
# You will not type it anywhere. Rotating it signs every phone and browser out.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		FRONT_URL=https://${DOMAIN_HOST}
		JWT_SECRET_KEY=$(openssl rand -hex 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-kitchenowl"
	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 8167 is not 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; 8167 stays on loopback"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The container migrates its database and imports a default item list on the way
# up, so the first start is slower than the ones after it.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}${HEALTH_PATH}"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}${HEALTH_PATH}" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the health route answered ${code:-nothing}. Check: docker compose logs --tail 40 kitchenowl"

health="$(curl -sS "https://${DOMAIN_HOST}${HEALTH_PATH}")"
printf '%s' "$health" | grep -qE '"msg":[[:space:]]*"OK"' \
	|| die "the health route answered 200 without msg OK. Check: docker compose logs --tail 40 kitchenowl"

# The server only prints open_registration when public signups are on, so its
# absence here is the report that they are off.
if printf '%s' "$health" | grep -q 'open_registration'; then
	die "public registration is on. Stop and check OPEN_REGISTRATION in compose.yml."
fi

curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>KitchenOwl</title>' \
	|| die "the root page did not return the KitchenOwl title. Check that Caddy is reaching 8167."

# With registration off the server publishes no signup route at all, so a 404
# here is the correct answer and a 200 would mean signups are open.
signup="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/auth/signup" || true)"
[ "$signup" = "404" ] || die "POST /api/auth/signup returned ${signup}, not 404. This install is not safe to leave running."

onboarding="$(curl -sS "https://${DOMAIN_HOST}/api/onboarding")"
if printf '%s' "$onboarding" | grep -qE '"onboarding":[[:space:]]*true'; then
	echo "==> the owner account is UNCLAIMED. Read item 1 below the moment this finishes."
else
	echo "==> onboarding already reports closed, so an account exists on this server"
fi

# --- 7. The first backup, before day one ends --------------------------------
#
# The container stops for the data archive: a SQLite file copied while it is
# being written is not a backup. The config archive picks up the live Caddy site
# block, not the <DOMAIN> template.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/kitchenowl-data-${STAMP}.tar.gz" -C "$APP_DIR" data
docker compose start
sudo tar -czf "$APP_DIR/backups/kitchenowl-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/kitchenowl-data-${STAMP}.tar.gz" ] || die "the data archive is empty"

cat <<-DONE

	KitchenOwl is answering at https://${DOMAIN_HOST}

	  1. DO THIS NOW. Open https://${DOMAIN_HOST}, press Start, and create your
	     account. The first account made through that form becomes the owner,
	     with admin rights, and until you make it that offer is open to anyone
	     who reaches your hostname. This script cannot open a browser, so it is
	     the one step it could not do for you. Confirm it took with
	       curl -sS https://${DOMAIN_HOST}/api/onboarding
	     whose onboarding field reads false once the account exists.
	  2. Everyone else in the household joins by invitation from inside the
	     app. Public signups are off and the server publishes no signup route.
	  3. The phone apps are the reason to run this: install KitchenOwl from
	     your app store, choose the option to use your own server, and give it
	     https://${DOMAIN_HOST}. That address is FRONT_URL in $APP_DIR/.env,
	     mode 600, and the signing key sits in the same file. Neither was
	     printed here.
	  4. First backup written to $APP_DIR/backups: the data directory and a
	     config archive. They are on the same disk as the data, which is not a
	     backup. Copy them somewhere else tonight.

DONE
```

## Also evaluated

Ranked below KitchenOwl for this swap. The prompts above install KitchenOwl only.

- **Mealie** — Your recipe box, meal plans and shopping lists on one container you own, with a Paprika importer and a clipper that reads most recipe sites. The right answer if the recipes are the reason you subscribed rather than the list. Mealie is a recipe box first, reads a Paprika or Tandoor export directly, clips from hundreds of sites, and its shopping list is generated out of a meal plan rather than being the thing you open every day. Same category of install, one container and SQLite, so the choice is genuinely about which half of AnyList you use. If both people in the house edit the list while shopping, KitchenOwl fits better; if one person collects recipes and prints a list on Sunday, this one does.

The page this mirrors: https://caniselfhostit.com/self-host/anylist-complete/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
