Can I self-host Paprika Recipe Manager?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called Mealie. It takes one prompt, a 1024 MB VPS, and about 10 minutes. There is no like-for-like Paprika Recipe Manager price to quote for this swap, so this page quotes none.
Why people pay for Paprika Recipe Manager
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.
Paprika is one of the few apps on this site that is genuinely cheap and genuinely good. You pay once per platform, there is no subscription, cloud sync between your own devices is included at no extra charge, and the apps are native, fast and work in a kitchen with no signal. What you are buying is a small studio's decade of care about the details of cooking from a screen. The reason to leave is not the money, because there is barely any: it is that your recipe collection lives in an account you do not administer, opens only in their apps, and cannot be handed to anyone in your household who has not bought their own copy.
| Plan | List price | What it buys |
|---|---|---|
| iOS | quote only | $4.99 once on the App Store, covering iPhone, iPad and Apple Watch. There is no monthly price to record because there is no subscription. |
| Macthe plan this page prices against | quote only | $29.99 once on the Mac App Store. Buying the iOS app does not unlock it: the vendor states that each version is sold separately. |
| Windows | quote only | $29.99 once, bought from paprikaapp.com. The caption under the button calls that figure a sale price, while the seasonal discount banner above it was commented out of the page on the day we read it. |
| Android | free | Free to install, capped at 50 recipes, and cloud sync is off. Google Play lists in-app purchases at $4.99 to $19.99 per item; one of them unlocks unlimited recipes and syncing. |
| Paprika Cloud Sync | free | No charge and no renewal. Syncing recipes, grocery lists and meal plans between the devices you bought the app on is included, which is the part of this ladder most rivals bill monthly for. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Mealie
One project, named before the prompt, so you know what you are about to install.
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 only one here that reads a Paprika export directly, so the collection you already have moves in one import rather than being retyped. It matches the parts people actually use daily, the URL clipper, the meal planner and the aisle-sorted shopping list, and it adds the two things a paid app on somebody else's sync service cannot: a web address every device in the house opens, and household accounts you hand out yourself. One container, SQLite inside it, and the seeded admin credential that upstream publishes is rotated during the install.
The swap
You'd run
Mealie
ONE COMMAND · ~10 min to running · 1024 MB RAM
Paprika Recipe Manager Mac · no like-for-like list price on the vendor's page, so this page quotes none
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
- ~10 minunder 10 minutes, through the first backup
The prompt
Two paths to the same Mealie: 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
313 lines · 14,995 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 Mealie 3.22.0 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
`BASE_URL`, which Mealie writes into the invitation links it sends the rest of the household,
so changing it later invalidates every invitation already out.
Mealie needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64; upstream does not support 32-bit ARM. 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 `armhf` too. 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.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mealie /srv/mealie/backups
sudo install -d -m 755 /srv/mealie/data
ls -la /srv/mealie
```
Assert: `ls -la` shows `backups` owned by the login user and `data` present. Everything Mealie
keeps lives under `data`: the SQLite database, the recipe photos, and the two key files it
writes for itself on first start. Leave its ownership alone. The image runs as uid 911 and
chowns /app on the way up, so after step 7 that directory belongs to 911 and is read back with
sudo. That is the image working as designed.
## 3. Secrets
One secret, generated here on the server: `ADMIN_PASSWORD`, which step 7 puts on the account
the image seeds in place of the password upstream publishes. Mealie writes its own token
signing keys at `data/.secret` and `data/.session_secret` on first start, so there is nothing
else to create. Hex, not base64: it travels inside a JSON body. Do not print it, do not repeat
it in your summary, and keep it out of every log line.
```bash
umask 077
cat > /srv/mealie/.env <<EOF
BASE_URL=https://<DOMAIN>
TZ=UTC
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/mealie/.env
umask 022
ls -l /srv/mealie/.env
```
Assert: the file exists with mode `-rw-------` and the login user's name twice. Docker Compose
reads it for the `${...}` substitutions in compose.yml whenever it runs from /srv/mealie, so
`BASE_URL` and `TZ` reach the container and the file is never mounted. `ADMIN_PASSWORD` is not
a Mealie setting and no container sees it: step 7 hands it to the running API and it stays here
as the user's only copy.
## 4. compose.yml
```bash
cat > /srv/mealie/compose.yml <<'EOF'
# Mealie · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install checklist .. https://docs.mealie.io/documentation/getting-started/installation/installation-checklist/
# sqlite sample ...... https://docs.mealie.io/documentation/getting-started/installation/sqlite/
# variable reference . https://docs.mealie.io/documentation/getting-started/installation/backend-config/
# backups ............ https://docs.mealie.io/documentation/getting-started/usage/backups-and-restoring/
#
# One service. Mealie ships a single all-in-one image with SQLite inside it, and
# upstream calls SQLite the right choice at one to twenty users, so there is no
# second container to run or dump. The image writes its own signing keys to
# /app/data/.secret and .session_secret on first start, which is why this
# install generates exactly one secret of its own: the password step 7 puts on
# the account the image seeds. The 1000M ceiling is upstream's recommendation
# for Python, which reserves far more than it needs on a large host. The image
# runs as uid 911 and chowns /app on start, so /srv/mealie/data ends up owned by
# 911 and is read back with sudo. Its own HEALTHCHECK covers /api/app/about, so
# none is repeated here. Tag and digest read from ghcr.io on 2026-08-06; the
# image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.22.0@sha256:36c28f0642fb6c75fae8997a2d55994631b9b4bcffba3016c208fc132a4c1e69
container_name: mealie
restart: unless-stopped
environment:
# Compose substitutes both from /srv/mealie/.env, which is mode 600 and is
# never mounted. ADMIN_PASSWORD is in that same file and deliberately not
# listed here, so no container ever sees it.
BASE_URL: ${BASE_URL}
TZ: ${TZ}
# Nobody can create an account from the login screen. The seeded admin
# invites the rest of the household instead.
ALLOW_SIGNUP: "false"
volumes:
- /srv/mealie/data:/app/data
deploy:
resources:
limits:
memory: 1000M
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8117.
- "127.0.0.1:8117:9000"
EOF
cd /srv/mealie && 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 deployment too and this install ignores it: SQLite in the same image is
what upstream recommends at household scale, 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-mealie
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mealie · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mealie.io/documentation/getting-started/installation/security/ 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
# BASE_URL in .env, which Mealie puts inside the invitation links it generates,
# so the two have to stay the same string.
<DOMAIN> {
# The recipe list is a JavaScript bundle and the API answers JSON. Caddy
# leaves the already-compressed recipe photos alone.
encode zstd gzip
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
}
# No Content-Security-Policy here: recipe pages embed YouTube and Vimeo
# players by design, and a policy written without testing those embeds
# breaks them in a way that reads as a broken recipe.
# 8117 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:8117
}
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-mealie, 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.
## 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 8117 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 8117.
## 7. Start and verify
Mealie runs its migrations on the way up, and that same start-up seeds one admin account whose
username and password upstream publishes in its installation checklist. Until the second half
of this step runs, that is a known credential on a public hostname. Bring it up and prove it
answers:
```bash
cd /srv/mealie
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/app/about); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/app/about
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
```
Assert all three, and print what you received for each: the loop ends on `200`; the JSON
contains `"version":"v3.22.0"`, the running container agreeing with the pinned digest, and
`"allowSignup":false`; the last command prints `<title>Mealie</title>`. If any of the three
misses, stop, run `docker compose logs --tail 40 mealie`, and name the likely earlier step: a
`502` instead of `200` means Caddy is reaching nothing on 8117, and a container restarting in a
loop points at step 2. A running container is not success.
Now close the seeded account. Its email is `changeme@example.com` and its password is
`MyPassword`, both printed in the upstream checklist, so anyone who reads that page can sign
in until this runs:
```bash
cd /srv/mealie
shipped=MyPassword
token=$(curl -sS -X POST https://<DOMAIN>/api/auth/token --data-urlencode 'username=changeme@example.com' --data-urlencode "password=$shipped" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
[ -n "$token" ] && echo "logged in"
printf '{"currentPassword":"%s","newPassword":"%s"}' "$shipped" "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' /srv/mealie/.env)" | curl -sS -o /dev/null -w '%{http_code}\n' -X PUT https://<DOMAIN>/api/users/password -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' --data-binary @-
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/auth/token --data-urlencode 'username=changeme@example.com' --data-urlencode "password=$shipped"
unset token shipped
```
Assert all three: `logged in`, then `200` from the update, then `401` from the second login.
That `401` is the security assert in this block and it decides whether this install is safe to
leave running. If the first line does not print `logged in`, nothing below it ran against a
real session, so stop there. If the last prints anything other than `401`, the published
password still works: stop, say so plainly, and do not report success. The new password was
piped into curl from the file rather than typed on a command line, so it never reaches the
process list, and neither it nor the token enters your output. The rejected login costs one
of the five tries Mealie allows before it locks an account for a day; the next successful
sign-in resets that counter.
The first screen at https://<DOMAIN> shows the wordmark `Mealie` over a `Sign in` heading, an
`Email or Username` box, a `Password` box and a `Login` button. Above them sits a first-login
banner printing the shipped email and password; it keys off that email address rather than the
password, so it goes on advertising one that no longer works.
STOP: tell the user to read their password with `grep ADMIN_PASSWORD /srv/mealie/.env`, put it
in their password manager, sign in at https://<DOMAIN> as `changeme@example.com`, and confirm
the recipe page loads. Wait. Do not continue until they confirm. Then tell them to put their
own address on the account under user settings, which is what clears that banner.
## 8. First backup and restore
Two artifacts. The data archive holds the SQLite database, the recipe photos and the two key
files Mealie wrote for itself; the config archive holds the files that rebuild the service
around them. Upstream's advice is to stop the container and copy `data` whole, which is what
runs here; downtime is a few seconds.
```bash
cd /srv/mealie
docker compose stop
sudo tar -czf /srv/mealie/backups/mealie-data-$(date +%F).tar.gz -C /srv/mealie data
docker compose start
sudo tar -czf /srv/mealie/backups/mealie-config-$(date +%F).tar.gz -C /srv/mealie compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/mealie/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/mealie
scp vps:/srv/mealie/backups/* ~/backups/mealie/
```
To restore: `docker compose down`, `sudo rm -rf /srv/mealie/data`,
`sudo tar -xzf /srv/mealie/backups/mealie-data-<date>.tar.gz -C /srv/mealie`, untar the config
archive into /srv/mealie so compose.yml and .env are back, then `docker compose up -d`. Tell
the user the fact that matters at 2am: the recipes, the photos and the signing keys all live
inside `data`, so restoring that one directory restores everything and nobody is logged out.
## 9. Updating later
New versions are listed at https://github.com/mealie-recipes/mealie/releases, and upstream asks
you to read the release notes before upgrading, not after. Take both backup artifacts first,
then edit the image line in /srv/mealie/compose.yml to the new tag and its digest:
```bash
cd /srv/mealie
docker compose pull
docker compose up -d
docker compose logs --tail 30 mealie
```
Mealie migrates its own database on the way up: watch that log until it settles, then re-run
the `/api/app/about` check from step 7 and confirm the version matches the tag you pinned.
## 10. What will probably go wrong
The first thing the user does after step 7 is paste a recipe URL, and one of the first few will
come back empty. I imported a dozen sites cleanly, then got a blank recipe from a large
publisher and read it as a broken scraper. It was not: that site answers a bot check instead of
a page, so there was no recipe markup to read. Mealie reads that markup from hundreds of sites
and cannot read a site that refuses to serve it, or one that draws the ingredients with
JavaScript after the page arrives. No setting fixes that. Tell the user to paste those recipes
in by hand and carry on; the sites that work are most of them.
## 11. Out of scope
- Do not configure SMTP. Mealie runs without it; the cost is invitation and password-reset
mail, and the admin can create household accounts by hand instead.
- Do not switch the database to PostgreSQL. SQLite in the one image is the choice here, and
moving between the two is a restore, not an edit.
- Do not add FlareSolverr or set `SCRAPER_PROXY_URL`. Those get past bot checks at the cost of
a second service and a third-party relay, for a problem step 10 solves with copy and paste.
- Do not enable OIDC or LDAP. Both need an identity provider this install does not have.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 Mealie 3.22.0 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 `BASE_URL`, the address Mealie writes into the
invitation links it sends the rest of your household, so changing it later invalidates every
invitation already out. 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. `armhf` on the third
line is a stop: upstream does not build Mealie for 32-bit ARM, and on a newer Raspberry Pi the
fix is a 64-bit operating system rather than anything in this prompt. Under 1024 MB available
is also a stop. Mealie is a Python service and this install caps it at 1000 MB on purpose, so a
box that cannot spare that will meet the OOM killer during your first bulk import.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mealie /srv/mealie/backups
sudo install -d -m 755 /srv/mealie/data
ls -la /srv/mealie
```
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 Mealie keeps goes under `data`:
the SQLite database, the recipe photos, and two key files it writes for itself the first time
it starts. Leave its ownership alone. The image runs as uid 911 and chowns /app on the way up,
so after step 7 `ls -la` will show `data` owned by `911` instead of by you. That is the image
working as designed, and it is why the backup command in step 8 uses `sudo`.
## 3. Secrets
One secret, generated here on the server: `ADMIN_PASSWORD`, which step 7 puts on the account
the image seeds in place of the password upstream publishes. Mealie writes its own token
signing keys into `data/` on first start, so there is nothing else to create. Replace
`<DOMAIN>` on the first line with your real hostname before you paste.
```bash
umask 077
cat > /srv/mealie/.env <<EOF
BASE_URL=https://<DOMAIN>
TZ=UTC
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/mealie/.env
umask 022
ls -l /srv/mealie/.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/mealie/.env` and
carry on. If the file already existed from an earlier attempt, this block has overwritten
`ADMIN_PASSWORD`, which is harmless before step 7 has run and a lockout afterwards, because the
account keeps the password it was actually given.
Do not paste that file, the password, or any command output containing it into this chat
window. The value is yours; read it once with `grep ADMIN_PASSWORD /srv/mealie/.env` after step
7 and put it straight into your password manager.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/mealie/compose.yml <<'EOF'
# Mealie · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install checklist .. https://docs.mealie.io/documentation/getting-started/installation/installation-checklist/
# sqlite sample ...... https://docs.mealie.io/documentation/getting-started/installation/sqlite/
# variable reference . https://docs.mealie.io/documentation/getting-started/installation/backend-config/
# backups ............ https://docs.mealie.io/documentation/getting-started/usage/backups-and-restoring/
#
# One service. Mealie ships a single all-in-one image with SQLite inside it, and
# upstream calls SQLite the right choice at one to twenty users, so there is no
# second container to run or dump. The image writes its own signing keys to
# /app/data/.secret and .session_secret on first start, which is why this
# install generates exactly one secret of its own: the password step 7 puts on
# the account the image seeds. The 1000M ceiling is upstream's recommendation
# for Python, which reserves far more than it needs on a large host. The image
# runs as uid 911 and chowns /app on start, so /srv/mealie/data ends up owned by
# 911 and is read back with sudo. Its own HEALTHCHECK covers /api/app/about, so
# none is repeated here. Tag and digest read from ghcr.io on 2026-08-06; the
# image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.22.0@sha256:36c28f0642fb6c75fae8997a2d55994631b9b4bcffba3016c208fc132a4c1e69
container_name: mealie
restart: unless-stopped
environment:
# Compose substitutes both from /srv/mealie/.env, which is mode 600 and is
# never mounted. ADMIN_PASSWORD is in that same file and deliberately not
# listed here, so no container ever sees it.
BASE_URL: ${BASE_URL}
TZ: ${TZ}
# Nobody can create an account from the login screen. The seeded admin
# invites the rest of the household instead.
ALLOW_SIGNUP: "false"
volumes:
- /srv/mealie/data:/app/data
deploy:
resources:
limits:
memory: 1000M
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8117.
- "127.0.0.1:8117:9000"
EOF
cd /srv/mealie && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/mealie/compose.yml` and paste again in one go. A warning that
`BASE_URL` is not set means you are not in /srv/mealie, or step 3 did not write the file:
Compose reads `.env` from the directory it runs in. There is no database container in this
file, and that is deliberate. Upstream documents a PostgreSQL deployment as well, and SQLite
inside the same image is what they recommend at one to twenty users, which 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-mealie
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mealie · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mealie.io/documentation/getting-started/installation/security/ 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
# BASE_URL in .env, which Mealie puts inside the invitation links it generates,
# so the two have to stay the same string.
<DOMAIN> {
# The recipe list is a JavaScript bundle and the API answers JSON. Caddy
# leaves the already-compressed recipe photos alone.
encode zstd gzip
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
}
# No Content-Security-Policy here: recipe pages embed YouTube and Vimeo
# players by design, and a policy written without testing those embeds
# breaks them in a way that reads as a broken recipe.
# 8117 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:8117
}
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-mealie /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 `8117`.
If you do not: delete anything for `8117` with `sudo ufw delete allow 8117`. 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 to read 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
Mealie runs its migrations on the way up, and that start-up seeds one admin account whose
username and password upstream publishes in its own installation checklist. Until the second
half of this step runs, that is a known credential on a public hostname, so do not stop halfway.
```bash
cd /srv/mealie
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/app/about); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/app/about
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
```
You should see, in order: the loop reaching `200`, a JSON object containing `"version":"v3.22.0"`
and `"allowSignup":false`, then `<title>Mealie</title>`.
If you do not: the loop can legitimately take a minute or two on a small box, because the
container migrates its database before it answers anything. Give it all thirty attempts before
touching anything. A `502` that never clears means Caddy is reaching nothing on 8117: check
`docker compose ps`. A version string that is not `v3.22.0` means the pull took a different
image than the digest in compose.yml, which is worth stopping over. Then close the seeded
account, which is the part of this install with real security meaning:
```bash
cd /srv/mealie
shipped=MyPassword
token=$(curl -sS -X POST https://<DOMAIN>/api/auth/token --data-urlencode 'username=changeme@example.com' --data-urlencode "password=$shipped" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
[ -n "$token" ] && echo "logged in"
printf '{"currentPassword":"%s","newPassword":"%s"}' "$shipped" "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' /srv/mealie/.env)" | curl -sS -o /dev/null -w '%{http_code}\n' -X PUT https://<DOMAIN>/api/users/password -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' --data-binary @-
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/auth/token --data-urlencode 'username=changeme@example.com' --data-urlencode "password=$shipped"
unset token shipped
```
You should see: `logged in`, then `200`, then `401`.
If you do not: that `401` is the whole point of the block. It is the shipped password being
refused, and until you see it your recipe manager is sitting on the public internet with a
password printed in upstream's documentation. Anything other than `401` on the last line means
stop and do not leave the service running. A missing `logged in` means nothing below it ran
against a real session, most often because the container had not finished starting; wait and
paste the block again. Note that one refused login costs one of the five attempts Mealie allows
before it locks the account for a day, so do not paste the block repeatedly to see the `401`
again. Your next successful sign-in resets that counter.
The first screen at https://<DOMAIN> shows the wordmark `Mealie` over a `Sign in` heading, an
`Email or Username` box, a `Password` box and a `Login` button. Above them sits a first-login
banner printing the shipped email and password. That banner keys off the seeded email address
rather than the password, so it goes on advertising one that no longer works.
Read your password once with `grep ADMIN_PASSWORD /srv/mealie/.env`, put it in your password
manager, then sign in at https://<DOMAIN> as `changeme@example.com`. Change that address to
your own under user settings straight away: it is what clears the banner, and it leaves the
account carrying your name rather than the one the image picked.
## 8. First backup and restore
Two artifacts. The data archive holds the SQLite database, the recipe photos and the two key
files Mealie wrote for itself; the config archive holds the files that rebuild the service
around them. Upstream's advice is to stop the container and copy `data` whole, which is what
this does.
```bash
cd /srv/mealie
docker compose stop
sudo tar -czf /srv/mealie/backups/mealie-data-$(date +%F).tar.gz -C /srv/mealie data
docker compose start
sudo tar -czf /srv/mealie/backups/mealie-config-$(date +%F).tar.gz -C /srv/mealie compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/mealie/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 belongs to uid 911 after the container's first start, 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/mealie
scp vps:/srv/mealie/backups/* ~/backups/mealie/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/mealie/`.
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 recipe box:
```bash
cd /srv/mealie
docker compose down
sudo rm -rf /srv/mealie/data
sudo tar -xzf /srv/mealie/backups/mealie-data-$(date +%F).tar.gz -C /srv/mealie
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/app/about
```
You should see: the same JSON as in step 7, and your browser session still signed in when you
reload the page.
If you do not: being signed out means the restore did not bring back `data/.secret`, which is
the file Mealie signs login tokens with, so check that the archive really was made with
`-C /srv/mealie data` and not from inside the directory. Understand the stakes before you skip
this: recipes you clipped over five years and photographed yourself are not on anyone else's
server any more, and this archive is the only copy.
## 9. Updating later
New versions are listed at https://github.com/mealie-recipes/mealie/releases, and upstream asks
you to read the release notes before upgrading, not after. Take both backup artifacts first,
then edit the `image:` line in /srv/mealie/compose.yml to the new tag and its digest.
```bash
cd /srv/mealie
docker compose pull
docker compose up -d
docker compose logs --tail 30 mealie
```
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
`/api/app/about` check from step 7 and confirm the version it prints is the tag you pinned,
because a container that answers on the old image is an update that did not happen.
## 10. What will probably go wrong
The first thing you will do after step 7 is paste a recipe URL, and one of the first few will
come back empty. I imported a dozen sites cleanly, then got a blank recipe from a large
publisher and read it as a broken scraper. It was not: that site answers a bot check instead of
a page, so there was no recipe markup to read. Mealie reads that markup from hundreds of sites
and cannot read a site that refuses to serve it, or one that draws the ingredients with
JavaScript after the page arrives. No setting fixes that. Paste those recipes in by hand and
carry on; the sites that work are most of them.
## 11. Out of scope
- Do not configure SMTP. Mealie runs without it; the cost is invitation and password-reset
mail, and the admin can create household accounts by hand instead.
- Do not switch the database to PostgreSQL. SQLite in the one image is the choice here, and
moving between the two is a restore, not an edit.
- Do not add FlareSolverr or set `SCRAPER_PROXY_URL`. Those get past bot checks at the cost of
a second service and a third-party relay, for a problem step 10 solves with copy and paste.
- Do not enable OIDC or LDAP. Both need an identity provider this install does not have.306 lines · 14,890 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 Mealie 3.22.0 under ~/selfhost/mealie, answering at http://localhost:8117.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install. Mealie is
at its best when the phone in the kitchen and the laptop in the study open the same recipe
list. Here there is one address, http://localhost:8117, and it means "this computer" wherever
it is typed, so the phone by the stove gets a connection error and nobody else in the household
can be invited.
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. Mealie needs 1024 MB of RAM available and
5 GB free on the home disk; the image publishes amd64 and arm64, not 32-bit ARM. 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/mealie/data ~/selfhost/mealie/backups
ls -la ~/selfhost/mealie
```
Assert: `ls -la` shows `data` and `backups`, both owned by the user. Everything Mealie keeps
goes under `data`: the database, the photos, and two key files it writes for itself on first
start. On Linux the image chowns it to uid 911 on first start, leaving it readable but
not writable by the user; Docker Desktop absorbs that on macOS and Windows.
## 4. Secrets
One secret: `ADMIN_PASSWORD`, which step 7 puts on the account the image seeds in place of the
password upstream publishes. Mealie writes its own token signing keys into `data/` on first
start, so there is nothing else to create. Generate it here, print it never, keep it out of
your summary and every log line.
```bash
umask 077
cat > ~/selfhost/mealie/.env <<EOF
BASE_URL=http://localhost:8117
TZ=UTC
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/mealie/.env
umask 022
ls -l ~/selfhost/mealie/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same
everywhere. Docker Compose reads it for the `${...}` substitutions in compose.yml when it runs
from ~/selfhost/mealie, so `BASE_URL` and `TZ` reach the container and the file is never
mounted. `ADMIN_PASSWORD` is not a setting and no container sees it.
On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/mealie/compose.yml <<'EOF'
# Mealie · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# install checklist .. https://docs.mealie.io/documentation/getting-started/installation/installation-checklist/
# sqlite sample ...... https://docs.mealie.io/documentation/getting-started/installation/sqlite/
# variable reference . https://docs.mealie.io/documentation/getting-started/installation/backend-config/
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/mealie/, so one file works on macOS, Linux and Windows and the
# recipes stay a folder you can open in Finder or Explorer. Mealie holds
# everything in SQLite inside its own image, so there is no database container,
# and it writes its own signing keys into data/ on first start. The 1000M
# ceiling is upstream's recommendation for Python. The image runs as uid 911 and
# chowns /app on start: on Linux that leaves ./data owned by 911, readable by
# you and writable with sudo, and Docker Desktop absorbs it elsewhere. Digest
# read on 2026-08-06; amd64 and arm64 published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.22.0@sha256:36c28f0642fb6c75fae8997a2d55994631b9b4bcffba3016c208fc132a4c1e69
container_name: mealie
restart: unless-stopped
environment:
# Compose substitutes both from ./.env, which is mode 600 and is never
# mounted. ADMIN_PASSWORD is in that same file and deliberately not listed
# here, so no container ever sees it.
BASE_URL: ${BASE_URL}
TZ: ${TZ}
# Nobody can create an account from the login screen.
ALLOW_SIGNUP: "false"
volumes:
- ./data:/app/data
deploy:
resources:
limits:
memory: 1000M
ports:
# Loopback only: no other device on the wifi can reach 8117.
- "127.0.0.1:8117:9000"
EOF
cd ~/selfhost/mealie && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. There is no database container: SQLite rides inside the same
image, which is what upstream recommends at household scale.
## 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.
8117 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. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/mealie/compose.yml
```
Assert: one line, `- "127.0.0.1:8117:9000"`. Nothing else in the file publishes a port.
## 7. Start and verify
Mealie migrates its database on the way up, and that start-up seeds one admin account whose
username and password upstream publishes. Bring it up and prove it answers:
```bash
cd ~/selfhost/mealie
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:8117/api/app/about); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8117/api/app/about
curl -sS http://localhost:8117/ | grep -o '<title>[^<]*</title>'
```
Assert all three, printing what you got for each: the loop ends on `200`; the JSON contains
`"version":"v3.22.0"` and `"allowSignup":false`; the last prints `<title>Mealie</title>`. If any
misses, stop, run `docker compose logs --tail 40 mealie`, and name the cause: a container still
working through migrations wants more time, one restarting in a loop points at step 3, and
`port is already allocated` means something else already holds 8117. A running container is not
success.
Now close the seeded account. Its email is `changeme@example.com` and its password is
`MyPassword`, both printed in the upstream checklist:
```bash
cd ~/selfhost/mealie
shipped=MyPassword
token=$(curl -sS -X POST http://localhost:8117/api/auth/token --data-urlencode 'username=changeme@example.com' --data-urlencode "password=$shipped" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
[ -n "$token" ] && echo "logged in"
printf '{"currentPassword":"%s","newPassword":"%s"}' "$shipped" "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' ~/selfhost/mealie/.env)" | curl -sS -o /dev/null -w '%{http_code}\n' -X PUT http://localhost:8117/api/users/password -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' --data-binary @-
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8117/api/auth/token --data-urlencode 'username=changeme@example.com' --data-urlencode "password=$shipped"
unset token shipped
```
Assert all three: `logged in`, then `200`, then `401`. Nothing here is reachable from another
machine, but a published password on a laptop that joins other networks is still a published
password. Anything other than `401` on the last line, stop and say so. The new
password was piped in from the file rather than typed, so it never reaches the process list,
and neither it nor the token enters your output. That refused login costs one of five tries
before a day's lockout.
The first screen at http://localhost:8117 shows the wordmark `Mealie` over a `Sign in` heading,
an `Email or Username` box, a `Password` box and a `Login` button, above them a first-login
banner printing the shipped email and password. It keys off the address, not the password, so
it keeps advertising one that no longer works.
STOP: tell the user to read their password with `grep ADMIN_PASSWORD ~/selfhost/mealie/.env`,
put it in their password manager, sign in at http://localhost:8117 as `changeme@example.com`,
and confirm the recipe page loads. Wait. Do not continue until they confirm. Then tell them to
put their own address on the account under user settings, which clears that banner.
## 8. First backup and restore
One archive holds everything that matters: the database, the photos and Mealie's own key files.
Upstream's advice is to stop the container and copy `data` whole, which is what this does; the
second archive holds the two files that rebuild the service.
```bash
cd ~/selfhost/mealie
docker compose stop
tar -C ~/selfhost/mealie -czf ~/selfhost/mealie/backups/mealie-data-$(date +%F).tar.gz data
docker compose start
tar -C ~/selfhost/mealie -czf ~/selfhost/mealie/backups/mealie-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/mealie/backups/
```
Assert: both files exist and both are non-empty. Print both sizes.
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 files are listed there. If they
have neither, say this install has no backup.
To restore: `cd ~/selfhost/mealie`, `docker compose down`, delete `data`, unpack the archive in
its place with `tar -xzf backups/mealie-data-<date>.tar.gz -C ~/selfhost/mealie`, untar the
config archive the same way, then `docker compose up -d`. On Linux those two commands need
`sudo` because the container owns the files; on macOS and Windows they run as they are.
Everything lives in that one directory, so restoring it signs nobody out.
## 9. Updating later
New versions are listed at https://github.com/mealie-recipes/mealie/releases; upstream asks you
to read the release notes before upgrading, not after. Take both backups first, then edit the
image line in ~/selfhost/mealie/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/mealie
docker compose pull
docker compose up -d
docker compose logs --tail 30 mealie
```
Watch that log until the migrations settle, then re-run the `/api/app/about` check from step 7
and confirm the version matches the tag you pinned.
## 10. What will probably go wrong
I rebooted this machine, opened http://localhost:8117 with the pan already hot, and got a
connection refused that read like a lost database. It was not: Docker Desktop had
not started with the session, so nothing was listening on 8117, and `restart: unless-stopped`
only acts once the daemon is up. Turn on Docker Desktop's start-at-login setting, and after any
reboot run `cd ~/selfhost/mealie && docker compose up -d` before concluding anything broke.
## 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 8117 to 0.0.0.0 so a phone in the kitchen can reach it. That puts a one-account
recipe manager on every network this machine joins.
- Do not switch to PostgreSQL, configure SMTP, OIDC or LDAP, or add FlareSolverr. This install
is one container with one account on one computer.compose.local.ymlthe services, pinned · local layout40 lines
# Mealie · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# install checklist .. https://docs.mealie.io/documentation/getting-started/installation/installation-checklist/
# sqlite sample ...... https://docs.mealie.io/documentation/getting-started/installation/sqlite/
# variable reference . https://docs.mealie.io/documentation/getting-started/installation/backend-config/
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/mealie/, so one file works on macOS, Linux and Windows and the
# recipes stay a folder you can open in Finder or Explorer. Mealie holds
# everything in SQLite inside its own image, so there is no database container,
# and it writes its own signing keys into data/ on first start. The 1000M
# ceiling is upstream's recommendation for Python. The image runs as uid 911 and
# chowns /app on start: on Linux that leaves ./data owned by 911, readable by
# you and writable with sudo, and Docker Desktop absorbs it elsewhere. Digest
# read on 2026-08-06; amd64 and arm64 published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.22.0@sha256:36c28f0642fb6c75fae8997a2d55994631b9b4bcffba3016c208fc132a4c1e69
container_name: mealie
restart: unless-stopped
environment:
# Compose substitutes both from ./.env, which is mode 600 and is never
# mounted. ADMIN_PASSWORD is in that same file and deliberately not listed
# here, so no container ever sees it.
BASE_URL: ${BASE_URL}
TZ: ${TZ}
# Nobody can create an account from the login screen.
ALLOW_SIGNUP: "false"
volumes:
- ./data:/app/data
deploy:
resources:
limits:
memory: 1000M
ports:
# Loopback only: no other device on the wifi can reach 8117.
- "127.0.0.1:8117:9000"agent-readable mirror: /self-host/paprika.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, pinned44 lines
# Mealie · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install checklist .. https://docs.mealie.io/documentation/getting-started/installation/installation-checklist/
# sqlite sample ...... https://docs.mealie.io/documentation/getting-started/installation/sqlite/
# variable reference . https://docs.mealie.io/documentation/getting-started/installation/backend-config/
# backups ............ https://docs.mealie.io/documentation/getting-started/usage/backups-and-restoring/
#
# One service. Mealie ships a single all-in-one image with SQLite inside it, and
# upstream calls SQLite the right choice at one to twenty users, so there is no
# second container to run or dump. The image writes its own signing keys to
# /app/data/.secret and .session_secret on first start, which is why this
# install generates exactly one secret of its own: the password step 7 puts on
# the account the image seeds. The 1000M ceiling is upstream's recommendation
# for Python, which reserves far more than it needs on a large host. The image
# runs as uid 911 and chowns /app on start, so /srv/mealie/data ends up owned by
# 911 and is read back with sudo. Its own HEALTHCHECK covers /api/app/about, so
# none is repeated here. Tag and digest read from ghcr.io on 2026-08-06; the
# image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v3.22.0@sha256:36c28f0642fb6c75fae8997a2d55994631b9b4bcffba3016c208fc132a4c1e69
container_name: mealie
restart: unless-stopped
environment:
# Compose substitutes both from /srv/mealie/.env, which is mode 600 and is
# never mounted. ADMIN_PASSWORD is in that same file and deliberately not
# listed here, so no container ever sees it.
BASE_URL: ${BASE_URL}
TZ: ${TZ}
# Nobody can create an account from the login screen. The seeded admin
# invites the rest of the household instead.
ALLOW_SIGNUP: "false"
volumes:
- /srv/mealie/data:/app/data
deploy:
resources:
limits:
memory: 1000M
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8117.
- "127.0.0.1:8117:9000"Caddyfilethe hostname and TLS31 lines
# Mealie · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mealie.io/documentation/getting-started/installation/security/ 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
# BASE_URL in .env, which Mealie puts inside the invitation links it generates,
# so the two have to stay the same string.
<DOMAIN> {
# The recipe list is a JavaScript bundle and the API answers JSON. Caddy
# leaves the already-compressed recipe photos alone.
encode zstd gzip
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
}
# No Content-Security-Policy here: recipe pages embed YouTube and Vimeo
# players by design, and a policy written without testing those embeds
# breaks them in a way that reads as a broken recipe.
# 8117 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:8117
}install.shthe same install, no agent182 lines
#!/usr/bin/env bash
# Mealie · 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=recipes.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.mealie.io/documentation/getting-started/installation/installation-checklist/
# https://docs.mealie.io/documentation/getting-started/installation/sqlite/
# https://docs.mealie.io/documentation/getting-started/installation/backend-config/
# https://docs.mealie.io/documentation/getting-started/usage/backups-and-restoring/
#
# One secret is generated here, on this machine: the password that replaces the
# one the image seeds its admin account with. It goes into /srv/mealie/.env with
# mode 600 and is never printed.
#
# The image seeds an account whose email and password upstream publishes. This
# script does not finish until that password has been replaced and the published
# one has been proved to fail.
#
# DOMAIN_HOST becomes BASE_URL, the address Mealie writes into the invitation
# links it sends. Changing it later invalidates invitations already out.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/mealie}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
SHIPPED_EMAIL="changeme@example.com"
SHIPPED="MyPassword"
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. recipes.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; Mealie 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 image runs as uid 911 and chowns /app on
# the way up, so this directory belongs to 911 after the first start.
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 inside a JSON body. Read it later with
# grep ADMIN_PASSWORD /srv/mealie/.env
# Mealie writes its own token signing keys into data/ on first start, so there
# is nothing else here to create.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
BASE_URL=https://${DOMAIN_HOST}
TZ=UTC
ADMIN_PASSWORD=$(openssl rand -hex 24)
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-mealie"
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 8117 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; 8117 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 -------------------------------------------------------------
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/app/about"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/app/about" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/app/about answered ${code:-nothing}. Check: docker compose logs --tail 40 mealie"
curl -sS "https://${DOMAIN_HOST}/api/app/about" | grep -q '"version":"v3.22.0"' \
|| die "the running container is not v3.22.0. Check: docker compose logs --tail 40 mealie"
curl -sS "https://${DOMAIN_HOST}/api/app/about" | grep -q '"allowSignup":false' \
|| die "open registration is on. Stop and check ALLOW_SIGNUP in compose.yml."
# --- 7. Replace the password the image seeds ---------------------------------
#
# Upstream publishes both halves of this credential in its installation
# checklist, so it is a known login on a public hostname until the next twenty
# lines have run. The new value is piped in from .env rather than written on a
# command line, so it never reaches the process list.
TOKEN="$(curl -sS -X POST "https://${DOMAIN_HOST}/api/auth/token" \
--data-urlencode "username=${SHIPPED_EMAIL}" \
--data-urlencode "password=${SHIPPED}" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
[ -n "$TOKEN" ] || die "could not sign in as the seeded account. Stop: the shipped password may already have been changed, or the API is not ready."
changed="$(printf '{"currentPassword":"%s","newPassword":"%s"}' "$SHIPPED" "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' "$APP_DIR/.env")" \
| curl -sS -o /dev/null -w '%{http_code}' -X PUT "https://${DOMAIN_HOST}/api/users/password" \
-H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' --data-binary @-)"
[ "$changed" = "200" ] || die "the password change returned ${changed}, not 200. Stop and investigate."
refused="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/auth/token" \
--data-urlencode "username=${SHIPPED_EMAIL}" \
--data-urlencode "password=${SHIPPED}" || true)"
[ "$refused" = "401" ] || die "the published password still returns ${refused}, not 401. This install is not safe to leave running."
unset TOKEN
# --- 8. The first backup, before day one ends --------------------------------
#
# Upstream's advice for SQLite is to stop the container and copy the data
# directory whole. 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/mealie-data-${STAMP}.tar.gz" -C "$APP_DIR" data
docker compose start
sudo tar -czf "$APP_DIR/backups/mealie-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/mealie-data-${STAMP}.tar.gz" ] || die "the data archive is empty"
cat <<-DONE
Mealie is answering at https://${DOMAIN_HOST}
1. Sign in as ${SHIPPED_EMAIL}. Your password is in $APP_DIR/.env,
mode 600. Read it with
grep ADMIN_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here. The
password the image ships with was replaced and proved to fail.
2. Change that email address to your own under user settings. The
sign-in page keeps showing a first-login banner with the published
credentials until you do, and the banner keys off the address.
3. Recipes import from a URL on most sites. Sites behind a bot check, or
that build the page with JavaScript, come back empty; paste those in
by hand. Existing Paprika, Tandoor and Nextcloud Cookbook exports
import at /group/migrations.
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 Paprika Recipe Manager.
- The account it starts with is public knowledge. Upstream prints the seeded email and its password in the installation guide, and Mealie's own sign-in page repeats them in a banner until you change the address. The install replaces that password and proves the old one is refused; changing the email is the one step left to you.
- URL import works on most recipe sites and not on all of them. A site behind a bot check, or one that draws its ingredients with JavaScript after the page loads, comes back empty. No setting fixes that, and the workaround is pasting the recipe in by hand.
- You own the backups. Everything is one directory holding the SQLite database, your photos and the keys that sign your logins, and the honest way to copy it is with the container stopped. A backup on the same disk is not a backup.
- It is a web app, not the native app you are leaving. You add it to a home screen and it needs your server to be reachable, so a phone with no signal in a shop has no shopping list unless you screenshot it first.
- No cloud sync account, no shared household unless you invite people yourself, and no vendor keeping the lights on. The domain, the certificate and the container are now things you renew.
Where this came from
“all your data is stored in the /app/data/ folder in the container. You can easily perform entire site backups by stopping the container, and backing up this folder with your chosen tool.”
- Upstream publishes the seeded first account in its installation checklist, as changeme@example.com with the password MyPassword, which is why this install rotates it and asserts the published one fails before it reports success. source
- Upstream calls SQLite the ideal choice for Mealie at one to twenty users and publishes a single-container compose sample for it, which is why this install runs no database container. source
- DB_ENGINE defaults to sqlite, ALLOW_SIGNUP has defaulted to false since v1.4.0 after a security review, and the API listens on port 9000 inside the container. source
- Upstream's own advice for a SQLite install is to stop the container and copy the /app/data folder whole, which is the backup this install takes. source
- Mealie creates recipes from hundreds of websites by URL, and imports existing collections from Paprika, Tandoor, Nextcloud Cookbooks, Recipe Keeper and others at /group/migrations. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Paprika Recipe Manager?
Not Paprika Recipe Manager 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 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 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 10 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 Paprika Recipe Manager?
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 only one here that reads a Paprika export directly, so the collection you already have moves in one import rather than being retyped. It matches the parts people actually use daily, the URL clipper, the meal planner and the aisle-sorted shopping list, and it adds the two things a paid app on somebody else's sync service cannot: a web address every device in the house opens, and household accounts you hand out yourself. One container, SQLite inside it, and the seeded admin credential that upstream publishes is rotated during the install. Mealie 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 Paprika Recipe Manager?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Mealie itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. There is no like-for-like Paprika Recipe Manager list price behind this swap, so this page does not invent a savings figure.
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 Mealie install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Mealie 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 Mealie 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: Mealie earns its keep when the phone in the kitchen and the laptop in the study open the same recipe list, and on this path only this computer can open it, so the phone by the stove gets a connection error. 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-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.