Self-Host Mealie on a VPS for a Family Recipe Manager
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.
TL;DR
- Install Docker and Docker Compose on a fresh VPS
- Point a subdomain like
recipes.example.comat your server - Run Mealie + PostgreSQL + Caddy with a single
docker-compose.yml - Set
ALLOW_SIGNUP=falseand create the first user withDEFAULT_EMAIL - Use the URL importer to pull recipes from cooking blogs in one click
- Back up
/app/dataplus a dailypg_dump
Total time: about 20 minutes.
What You Need
- A VPS with at least 1 GB RAM (2 GB is more comfortable) running Ubuntu 22.04 or 24.04
- A domain name you can add DNS records to
- Ports
80and443open to the internet (required by Let's Encrypt) - Root or sudo access
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.
Step 1: Point a Subdomain at Your VPS
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.
Step 2: Install Docker and Docker Compose
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
Step 3: Open the Firewall
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.
Step 4: Create the Project Directory
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.
Step 5: Generate Secrets
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.
Step 6: Write the Environment File
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_URLmust match the public URL exactly, includinghttps://. Mealie uses it to build absolute links in invite emails and the PWA manifest.ALLOW_SIGNUP=falseis the single most important setting in this file. Keep it off.DEFAULT_EMAILandDEFAULT_PASSWORDonly seed the first user on first boot. After that they are ignored. Change the password from the UI once you log in.
Step 7: Write the Compose File
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:
- Mealie's container port
9000stays internal. Caddy reaches it over the Docker network, so there's no need to expose it on the host. - The
/app/datavolume holds uploaded recipe images, backups, and the search index. Losing it means rescraping every recipe. ghcr.io/mealie-recipes/mealie:latestworks fine for hobby installs. Pin to a specific tag like:v2.5.0once you have a setup you like, so an automatic pull doesn't surprise you.
Step 8: Write the Caddyfile
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.
Step 9: Start the Stack
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.
Step 10: Create a Household and Invite Family Members
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:
- Click your avatar, go to Admin then Users.
- Click Invite User, fill in the email, and pick the household.
- Mealie generates a one-time invite link. Send it over Signal, iMessage, or whatever the family uses.
- They open the link, set a password, and they're in.
This is the supported way to add accounts when ALLOW_SIGNUP=false. No public registration form, no bots probing your subdomain.
Step 11: Import Your First Recipe From a URL
The headline feature. Find any recipe on a normal cooking blog and copy the URL. In Mealie:
- Click the green + button, choose Import.
- Paste the URL into the From a Website (URL) box.
- Hit Import.
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.
Step 12: Meal Planning and Shopping Lists
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:
- Plan a week on Sunday, generate the shopping list, then both adults can check items off in the store from their phones.
- Tag recipes by season or by who-cooks-what, then filter the recipe page when you sit down to plan.
- Use Random Meal in the planner if Sunday-night-decision-fatigue is real. Lock dietary tags so the random pick respects them.
Step 13: Install the Mobile PWA
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.
Step 14: Back Up Mealie
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.
Step 15: Upgrade Mealie Safely
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.
Troubleshooting
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.
Going Further
- Try Tandoor as an alternative if you want sharper meal planning, supermarket-aware shopping lists, and tighter inventory tracking. The trade-off is a slightly less polished mobile experience.
- Wire up Apprise notifications so meal plan reminders ping a Telegram chat or a Discord channel before dinner prep.
- Hook Mealie into Home Assistant with the community integration. The "what's for dinner" card on a kitchen tablet is a nice quality-of-life win.
- Run multiple households in one group for an extended family share. Each household keeps a separate meal plan and shopping list while sharing the recipe library.
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.