Recipe sites have gotten unbearable. Forty paragraphs of life story, three video ads, a newsletter modal, and the actual ingredient list buried somewhere in the middle. Saving recipes to a phone notes app works for one or two, but it falls apart the moment two people in the same household want to plan dinner together.
Mealie fixes both problems. It's an open-source self-hosted recipe manager with a slick Vue front end, a real database underneath, and a recipe importer that takes a URL from any cooking blog and pulls out just the recipe. It also handles meal planning, shopping lists, multiple households, and ships a mobile PWA so it feels like a native app on a phone.
This guide walks through a production-ready install on a VPS: Docker Compose, PostgreSQL, Caddy for automatic HTTPS, and a backup script that covers both the database and the uploaded images.
recipes.example.com at your serverdocker-compose.ymlALLOW_SIGNUP=false and create the first user with DEFAULT_EMAIL/app/data plus a daily pg_dumpTotal time: about 20 minutes.
80 and 443 open to the internet (required by Let's Encrypt)Mealie is light. Idle memory is around 200 MB and a small VPS handles a household of four or five with thousands of recipes without breaking a sweat.
In your DNS provider, add an A record:
recipes.example.com → YOUR_VPS_IPV4
Add an AAAA record for IPv6 if you use it. Verify propagation:
dig +short recipes.example.com
DNS needs to resolve before Caddy can fetch a Let's Encrypt certificate.
On a fresh Ubuntu server:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm everything is wired up:
docker --version
docker compose version
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS.
sudo mkdir -p /opt/mealie
cd /opt/mealie
sudo mkdir -p mealie-data postgres-data caddy-data caddy-config
Everything persistent lives under /opt/mealie. Back up that one directory plus a database dump and you can rebuild the stack on any new box.
Two secrets to mint before writing the compose file:
# PostgreSQL password
openssl rand -base64 32
# Initial admin password for the first user
openssl rand -base64 24
Save both somewhere safe. The admin password is what you'll use the first time you log in.
Create /opt/mealie/.env:
# Domain
BASE_URL=https://recipes.example.com
# Database
POSTGRES_USER=mealie
POSTGRES_PASSWORD=REPLACE_WITH_POSTGRES_PASSWORD
POSTGRES_DB=mealie
# Mealie security
ALLOW_SIGNUP=false
DEFAULT_GROUP=Family
[email protected]
DEFAULT_PASSWORD=REPLACE_WITH_INITIAL_ADMIN_PASSWORD
# Locale and timezone
TZ=Europe/Berlin
DEFAULT_LOCALE=en-US
Lock it down so only root can read it:
sudo chmod 600 /opt/mealie/.env
A note on the variables:
BASE_URL must match the public URL exactly, including https://. Mealie uses it to build absolute links in invite emails and the PWA manifest.ALLOW_SIGNUP=false is the single most important setting in this file. Keep it off.DEFAULT_EMAIL and DEFAULT_PASSWORD only seed the first user on first boot. After that they are ignored. Change the password from the UI once you log in.Create /opt/mealie/docker-compose.yml:
services:
postgres:
image: postgres:16
container_name: mealie-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mealie-net
mealie:
image: ghcr.io/mealie-recipes/mealie:latest
container_name: mealie
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
TZ: ${TZ}
BASE_URL: ${BASE_URL}
ALLOW_SIGNUP: ${ALLOW_SIGNUP}
DEFAULT_GROUP: ${DEFAULT_GROUP}
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
DEFAULT_LOCALE: ${DEFAULT_LOCALE}
DB_ENGINE: postgres
POSTGRES_SERVER: postgres
POSTGRES_PORT: "5432"
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./mealie-data:/app/data
networks:
- mealie-net
caddy:
image: caddy:2
container_name: mealie-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- mealie-net
networks:
mealie-net:
A couple of details worth flagging:
9000 stays internal. Caddy reaches it over the Docker network, so there's no need to expose it on the host./app/data volume holds uploaded recipe images, backups, and the search index. Losing it means rescraping every recipe.ghcr.io/mealie-recipes/mealie:latest works fine for hobby installs. Pin to a specific tag like :v2.5.0 once you have a setup you like, so an automatic pull doesn't surprise you.Create /opt/mealie/Caddyfile:
recipes.example.com {
encode zstd gzip
reverse_proxy mealie:9000
}
Caddy will request and renew a Let's Encrypt certificate automatically. No further configuration.
cd /opt/mealie
sudo docker compose up -d
sudo docker compose logs -f
Watch the logs until Caddy reports the certificate was issued and Mealie finishes its first migration. Then open https://recipes.example.com. You should land on the login screen.
Sign in with the email and password you set in DEFAULT_EMAIL / DEFAULT_PASSWORD. The first thing to do once you're in: open the user menu, change the password, and update the display name.
Mealie's data model has three layers: users, households, and groups. A group is a tenant (think Family), a household is a planning unit inside that group (Main House, Lake Cabin, Grandma), and users belong to one or more households.
For most families, one group with one or two households is plenty.
To invite people:
This is the supported way to add accounts when ALLOW_SIGNUP=false. No public registration form, no bots probing your subdomain.
The headline feature. Find any recipe on a normal cooking blog and copy the URL. In Mealie:
Mealie scrapes the page using the open recipe-scrapers library, pulls out the title, ingredients, instructions, hero image, prep time, cook time, yield, and any nutrition data. Most well-formatted blogs come through clean. Sites with JSON-LD schema (which is the majority) work especially well.
After import, edit the recipe to clean up anything weird, add your own tags (weeknight, kid-friendly, vegetarian), and assign a category. Mealie's tagging is the killer feature for finding "what can we make tonight" later.
Open Meal Plans in the sidebar. The planner is a calendar grid: drag a recipe onto a day, mark it as breakfast, lunch, dinner, or snack, and Mealie tracks the schedule per household.
Shopping lists are linked. Open a planned recipe, click Add Ingredients to Shopping List, and Mealie aggregates ingredients across recipes (so two recipes calling for an onion become "2 onions" on one line). The list syncs in real time across devices, which is the part that makes Mealie click for two-cook households.
Useful planning patterns:
Mealie ships as a Progressive Web App. There's no separate iOS or Android binary; the website is the app.
On iPhone, open https://recipes.example.com in Safari, tap the share button, then Add to Home Screen. On Android, Chrome offers an Install app prompt automatically, or you can pick Install app from the menu.
The PWA runs in its own window without browser chrome, has a proper app icon, and works offline for already-loaded recipes. Push notifications aren't available out of the box, but for "what's for dinner" purposes the PWA is indistinguishable from a native app.
Two things need backing up: the /app/data directory (uploaded images, the search index, internal backups) and the PostgreSQL database (recipes, plans, users, ratings).
Create /usr/local/bin/mealie-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/mealie"
DATE="$(date +%F)"
mkdir -p "$BACKUP_DIR"
docker exec mealie-postgres \
pg_dump -U mealie -d mealie \
| gzip > "$BACKUP_DIR/mealie-db-$DATE.sql.gz"
tar -czf "$BACKUP_DIR/mealie-data-$DATE.tar.gz" -C /opt/mealie mealie-data
find "$BACKUP_DIR" -name "mealie-*.tar.gz" -mtime +14 -delete
find "$BACKUP_DIR" -name "mealie-*.sql.gz" -mtime +14 -delete
Make it executable and schedule it nightly:
sudo chmod +x /usr/local/bin/mealie-backup.sh
echo "20 3 * * * root /usr/local/bin/mealie-backup.sh" | \
sudo tee /etc/cron.d/mealie-backup
For off-site safety, sync the backup directory to S3, Backblaze B2, or another VPS with rclone on the same schedule. Recipes you collected over a decade are not the kind of thing you want to rebuild from memory.
Mealie ships frequent updates. The safe upgrade path:
cd /opt/mealie
sudo /usr/local/bin/mealie-backup.sh
sudo docker compose pull
sudo docker compose up -d
Always take a backup before upgrading, especially across major versions. Database migrations occasionally need a manual touch on big releases; the changelog calls those out.
Importer fails on certain blogs. A handful of sites hide content behind Cloudflare's bot mitigation or paywall the recipe portion. The scraper sees a challenge page and bails. Workarounds: copy the recipe and paste it into the Create from Scratch form, or use the browser bookmarklet that ships with Mealie to scrape from your already-authenticated browser session.
Images do not load after a restart. Almost always a volume permissions issue. Mealie runs as UID 911 by default. Fix it once with sudo chown -R 911:911 /opt/mealie/mealie-data then docker compose restart mealie.
Search returns nothing for recipes you know exist. The full-text search index lives in /app/data and can fall behind after a bulk import or restore. Trigger a rebuild from Admin then Maintenance then Reindex Recipes. It runs in seconds for a few thousand recipes.
Can't log in after an upgrade. Check the Mealie container logs first; a database migration may have failed mid-way. Bring the stack down with docker compose down, restore the latest mealie-db-*.sql.gz into PostgreSQL, then pin the previous image tag in docker-compose.yml and bring it back up. Open the changelog before retrying the upgrade.
Self-hosting Mealie turns a chaotic mess of bookmarked recipes and screenshotted ingredient lists into one searchable family cookbook that nobody else owns.
Looking for a VPS that's ready for stacks like this? Our Linux plans come with NVMe storage and IPv6 out of the box. See the options.