Can I self-host AnyList?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called KitchenOwl. It takes one prompt, a 1024 MB VPS, and about 9 minutes. That is $1.25 a month you stop paying AnyList — $15 a year on the Complete (household) plan.
Why people pay for AnyList
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.
AnyList is cheap, and the reason people pay is not the feature list. It is that a shared grocery list has to be right on two phones in two different aisles at the same second, and AnyList has done that reliably for over a decade on hardware nobody in the household administers. The subscription buys sync that has never dropped an item, native apps on the platforms a family already carries, and the fact that the person who does not care about any of this can install it and it works. Leaving is not a money decision at fifteen dollars a year; it is a decision about whose account the household's shopping list lives in.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | No charge. Creating lists and sharing them with family and friends is in the free tier. The vendor's own /complete page lists recipe web import, the meal planning calendar, item and recipe photos, price and budget tracking, premium themes, folders, location reminders, passcode lock, cloud backup and Mac or PC web access as what the paid tier unlocks; the comparison table at /features draws two of those lines differently, so read this row as marketing rather than a contract. |
| Complete (individual) | $0.83/mo | $9.99 a year, billed annually. There is no monthly plan, so 0.83 is that annual figure divided by twelve rather than a price the vendor quotes. |
| Complete (household)the plan this page prices against | $1.25/mo | $14.99 a year, billed annually, and 1.25 is that figure divided by twelve. The page sells this as covering a household and does not say how many people that is, so no seat count is recorded. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by KitchenOwl
One project, named before the prompt, so you know what you are about to install.
A shared grocery list your household's phones open from their own app stores, pointed at a server you run.
The only one here that keeps the part of AnyList people actually use: a shopping list that two phones in a supermarket both hold, ticked off in real time, with real apps on Google Play, F-Droid and the App Store that you point at your own server. That client story is what usually breaks when a household app becomes self-hosted, and it is the whole reason this ranks first. One container, SQLite inside it, one generated key, and the household model, meal planner and shared expenses arrive with it. What you give up against AnyList is the Apple side: no Apple Watch app, no location-based reminders, no passcode lock on a list.
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.
- KitchenOwlour pickONE COMMAND— setup effort 1 of 4
A shared grocery list your household's phones open from their own app stores, pointed at a server you run.
The only one here that keeps the part of AnyList people actually use: a shopping list that two phones in a supermarket both hold, ticked off in real time, with real apps on Google Play, F-Droid and the App Store that you point at your own server. That client story is what usually breaks when a household app becomes self-hosted, and it is the whole reason this ranks first. One container, SQLite inside it, one generated key, and the household model, meal planner and shared expenses arrive with it. What you give up against AnyList is the Apple side: no Apple Watch app, no location-based reminders, no passcode lock on a list.
- MealieONE COMMAND— setup effort 1 of 4
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 swap
You'd run
KitchenOwl
ONE COMMAND · ~9 min to running · 1024 MB RAM
AnyList Complete (household) · 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
- ~9 minunder 10 minutes, through the first backup
The prompt
Two paths to the same KitchenOwl: 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
316 lines · 14,975 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 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.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 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.303 lines · 14,994 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 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.compose.local.ymlthe services, pinned · local layout39 lines
# 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"agent-readable mirror: /self-host/anylist-complete.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, pinned40 lines
# 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"Caddyfilethe hostname and TLS36 lines
# 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.shthe same install, no agent183 lines
#!/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.
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 AnyList.
- The owner account is a race you have to win. KitchenOwl creates its first user through an onboarding form that is open to anyone who reaches your hostname, and that user gets admin rights. The install closes the window by making you claim it at a stop and then asserting the door is shut, but for the minutes between the container starting and you finishing that form, the offer is public.
- The phone apps are the point, and they are also the cost. Every person in the household installs KitchenOwl from their own app store and types your hostname into it once, which is the thing most self-hosted household apps cannot do. In exchange, the hostname becomes load-bearing: change it and you visit every phone, and the apps update on Apple's and Google's schedule rather than yours, so a server left months behind can stop talking to a phone that updated itself.
- You own the backups, and the honest copy needs the container stopped. Everything is one SQLite file and one upload directory under /srv/kitchenowl/data, and a SQLite file copied while it is being written is not a backup. A backup on the same disk is not one either.
- Three things on AnyList's paid feature list do not come across, and no setting brings them: the Apple Watch app, location-based reminders that fire when you walk into a shop, and a passcode lock on a list. Those are platform features of a native iOS app, not server features, and this install has no answer to them.
- There is no password reset unless you configure mail, and this install does not. Forget the owner password and the way back in is the command line on your own server. Household members join by invitation from inside the app, so the only account recovery anyone has is you.
Where this came from
“It is also important that you have HTTP Strict Transport Security enabled and the proper headers applied to your responses or you could be subject to a javascript hijack.”
- Upstream publishes an all-in-one image next to the split front-and-back pair, and its own sample for it runs one service with one published port and one /data volume, which is the shape this install takes. source
- DB_DRIVER defaults to sqlite, STORAGE_PATH defaults to /data, OPEN_REGISTRATION defaults to false, and FRONT_URL has to match the server's own URL including the scheme. source
- The onboarding endpoint creates the first user with admin rights and refuses once any user exists, so the first person to complete that form owns the server. source
- The signup route is only registered when OPEN_REGISTRATION is on, which is why a server with public signups off answers 404 there rather than refusing a request. source
- KitchenOwl ships apps on Google Play, F-Droid and the App Store, and they can be pointed at a server you run instead of the hosted one. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host AnyList?
Not AnyList 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 KitchenOwl. A shared grocery list your household's phones open from their own app stores, pointed at a server you run. The install is one command: one container 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 9 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 AnyList?
KitchenOwl. A shared grocery list your household's phones open from their own app stores, pointed at a server you run. The only one here that keeps the part of AnyList people actually use: a shopping list that two phones in a supermarket both hold, ticked off in real time, with real apps on Google Play, F-Droid and the App Store that you point at your own server. That client story is what usually breaks when a household app becomes self-hosted, and it is the whole reason this ranks first. One container, SQLite inside it, one generated key, and the household model, meal planner and shared expenses arrive with it. What you give up against AnyList is the Apple side: no Apple Watch app, no location-based reminders, no passcode lock on a list. Also evaluated and ranked below it: Mealie. The prompt on this page installs KitchenOwl only. KitchenOwl is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to AnyList?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. KitchenOwl itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: AnyList Complete (household), $1.25/mo — $15 a year.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the KitchenOwl install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run KitchenOwl 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 KitchenOwl 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: The list answers only at http://localhost:8167 on this computer, so the phone in the supermarket has nothing to point at and the household half of the app never comes on. 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.