SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-13

Self-hosted recipe manager: Mealie on a VPS

Run Mealie on your own VPS with Docker Compose. Paste a recipe link and get clean steps, plus meal plans, shopping lists, nginx, TLS and backups.

What a self-hosted recipe manager does

A self-hosted recipe manager keeps your recipes in a database on a server you own, and Mealie is the one most households settle on. You paste the address of a recipe page and Mealie reads the ingredients, the steps, the yield and the cook time out of it, then leaves the story and the advertising behind. What lands in your collection is the food.

The rest of the app is small. There is a weekly meal plan you drag recipes into, and a shopping list that is built from that plan. Each person who cooks gets their own login. All of it runs in one container, and it sits idle between requests, so a modest VPS carries it without noticing.

This guide uses Docker Compose. If the words services: and volumes: are new to you, read how Docker Compose files are put together first, because everything below is one compose file and four commands.

Install Mealie with Docker Compose

Mealie publishes its images to the GitHub container registry. As of July 2026 the current stable tag is v3.22.0. Pin a version rather than using latest: with latest, a docker compose pull on an unrelated day can move you across a database migration you were not ready for.

sudo mkdir -p /srv/mealie
cd /srv/mealie
sudo nano docker-compose.yml
services:
  mealie:
    image: ghcr.io/mealie-recipes/mealie:v3.22.0
    container_name: mealie
    restart: always
    ports:
      - "127.0.0.1:9925:9000"
    deploy:
      resources:
        limits:
          memory: 1000M
    volumes:
      - mealie-data:/app/data/
    environment:
      ALLOW_SIGNUP: "false"
      PUID: 1000
      PGID: 1000
      TZ: Europe/Amsterdam
      BASE_URL: https://recipes.example.com

volumes:
  mealie-data:

Two lines deserve a look before you start it.

The port is written as 127.0.0.1:9925:9000 and not as 9925:9000. The container listens on 9000 inside, and the host maps 9925 to it. Binding that map to the loopback address means nginx can reach Mealie and the internet cannot. Docker writes its own rules into the packet filter, so a plain 9925:9000 is reachable from outside even when your firewall says the port is closed. That surprise is worth understanding once: see why published Docker ports ignore ufw.

BASE_URL must be the exact public address you will use, with the scheme and no trailing slash. Mealie builds password reset links and invitation links from it. Set it to http://localhost:9925 and the invitation you send your partner will contain a link that only works on the server itself.

Start it and watch the first boot.

sudo docker compose up -d
sudo docker compose logs -f mealie

The first start creates the SQLite database and runs the migrations, which takes a few seconds. When the log settles and stops printing migration lines, check the app locally.

curl -I http://127.0.0.1:9925

A 200 OK means the app is up. Connection refused means the container is not running: run sudo docker compose ps and read the exit code. A container that stopped with code 137 was killed for exceeding the 1000M memory limit, which happens on the smallest plans.

First login, and turning off open signup

The default account is changeme@example.com with the password MyPassword. Log in with it, then change both immediately, because that pair is printed in the documentation and is therefore in every scanner.

ALLOW_SIGNUP: "false" in the compose file is deliberate. With signup open, anyone who finds the address can create an account in your recipe box. With it closed, you add people from the admin area, which generates an invitation link you send them yourself. That link is built from BASE_URL, which is why the value matters. If you end up running several apps on the same server and want one password for all of them, Mealie can hand its login to an external identity provider such as a self-hosted Authentik instance.

Mealie groups users into a household. Everyone in one household shares the recipe collection, the meal plan and the shopping list, which is what a family wants. Separate households on the same server keep separate collections, which is what a flatshare wants when nobody agrees about anchovies.

The importer, which is the reason to run this

Open the recipe collection, choose to create a recipe from a URL, and paste a link. Mealie fetches the page and looks for structured recipe data, the machine-readable block that most recipe sites embed for search engines. When that block is present the import is clean and immediate.

You can also import from an image or from plain text you paste in, which covers a photograph of a page from a cookbook. Those go through a slower path and want checking afterwards, since a handwritten fraction is easy to misread.

Bulk imports run from the same screen: paste a list of addresses, one per line, and Mealie works through them in the background. A collection of two hundred bookmarks moves across in one sitting.

Meal plans and the shopping list

The meal planner is a calendar. Drag a recipe onto a day and it is planned. The shopping list then collects the ingredients from the planned recipes into one list and combines duplicates, so two recipes wanting onions produce one line instead of two.

The list is a live page on your phone in the shop. Because your own server holds it, everyone in the household sees the same list at the same time, and one person ticking off milk removes it from the other person's screen.

Put nginx and TLS in front

Mealie speaks plain HTTP and has no certificate handling of its own. Terminate transport layer security (TLS) in nginx in front of it. Point a DNS A record at your server first, since the certificate step verifies that name.

sudo apt update && sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/mealie
server {
    listen 80;
    server_name recipes.example.com;

    client_max_body_size 64M;

    location / {
        proxy_pass http://127.0.0.1:9925;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo ln -s /etc/nginx/sites-available/mealie /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

nginx -t printing syntax is ok and test is successful is the gate. Reload only after it passes, because reloading a broken config leaves the old one running and hides the mistake until the next restart.

client_max_body_size 64M is there because the nginx default is 1 MB. Uploading a recipe photo or restoring a backup through the browser sends a larger body than that, and without the line you get a 413 Request Entity Too Large from nginx, not from Mealie, so the application log shows nothing at all.

Then issue the certificate. That step and its renewal timer are covered in issuing a Let's Encrypt certificate for nginx with certbot.

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d recipes.example.com

Certbot rewrites the server block to listen on 443 and adds a redirect from port 80. Load the site over https:// and confirm the browser accepts the certificate. If Mealie loads but its own links send you to http://, then BASE_URL still says http and needs correcting, followed by sudo docker compose up -d to recreate the container with the new value.

Serving Mealie under a subpath such as example.com/recipes does not work, because the frontend cannot be served from a subpath. Use a subdomain.

Backups, and what a restore really does

Everything Mealie owns lives in /app/data/ inside the container, which is the mealie-data volume. Copy that volume and you have copied the recipes, the images and the database together.

sudo docker volume ls
sudo docker compose stop mealie
sudo docker run --rm -v mealie_mealie-data:/data -v "$PWD":/backup \
  alpine tar czf /backup/mealie-data.tgz -C /data .
sudo docker compose start mealie

The volume name carries the project name as a prefix, and the project name is the directory holding the compose file. From /srv/mealie the volume is mealie_mealie-data, which is why the first command is docker volume ls: use the name it prints, not the name in this guide. Stopping the container first matters, because SQLite is mid-write often enough that a live copy can restore unreadable.

Mealie also has its own backup page in the admin area, which writes a portable archive holding the database as JSON alongside your images. Use it for moving between servers, since it survives a version change that a raw file copy might not. Restoring one is destructive by design: it deletes the current database before loading the archive, and it cannot be undone. You are logged out when it finishes.

Neither copy is a backup while it sits on the same server. Push the archive somewhere else on a schedule, which is what encrypted off-server backups with restic is for.

Updating Mealie

cd /srv/mealie
sudo nano docker-compose.yml
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs -f mealie

Raise the pinned version in the file, then pull and recreate. Migrations run on the first start of the new image. Take a copy of the volume before a major version change, because a migration that fails halfway leaves a database the previous image will no longer open. Read the release notes for everything between your version and the new one.

When the importer fails

Some sites publish no structured recipe data at all, and Mealie then imports a title with an empty ingredient list. That is not something you can configure away. Paste the recipe text in manually instead.

Other failures come from bot protection in front of the recipe site, which answers Mealie with a challenge page rather than the recipe. Mealie already impersonates a browser and rotates its user agent to reduce this. When a site still refuses, the documented options are to send the scraper through a proxy with a better address reputation, or to run a FlareSolverr instance that solves the challenge in a real browser. Both are optional, and both are set through environment variables on the container.

An import that fails because your server cannot reach the site at all is a different problem. Test it from the box with curl -I https://the-site.example/recipe and read the status line before blaming the scraper.

Where it fits

Mealie is a good first self-hosted application for a household, because the people you live with will use it without being asked. It is the same shape of job as running your own photo library with Immich, though much lighter, and it sits in the wider list of things worth self-hosting this year. One small server holds both. Immich is not the only candidate for that second job, and if you are still deciding, the memory floors and backup commands of PhotoPrism and Immich differ enough to be worth reading before you give away the rest of the disk. If the household keeps its plans and notes somewhere as well as its dinners, a self-hosted AFFiNE workspace is the same compose-file exercise again, but it wants four containers and a good deal more memory than Mealie asks for, so check what the box has left first.

FAQ

Why does importing a recipe URL fail?

There are two common causes. Either the page publishes no structured recipe data, so the scraper finds nothing and you get a title with no ingredients, or a bot-protection layer in front of the site returns a challenge page instead of the recipe. For the second case Mealie can be pointed at a proxy with a better address reputation, or at a self-hosted FlareSolverr instance that solves the challenge in a real browser. Confirm your server can reach the page at all with curl -I before changing anything.

Do I need PostgreSQL, or is SQLite enough?

SQLite is enough for a household, and it is the default. Move to PostgreSQL when the data directory lives on network attached storage, because SQLite over a network filesystem produces locked-database errors and can corrupt the file. Restores under PostgreSQL need the database user to be a superuser, since the restore deletes everything before loading the archive.

Can I run Mealie without a domain name?

Yes, on your own network. Set BASE_URL to the address you will actually type, such as http://192.168.1.20:9925, and skip nginx. Invitation and password reset links are built from BASE_URL, so a wrong value produces links nobody else can open. Do not expose it to the internet over plain HTTP, because the login is then sent in the clear.

How do I give my family their own logins?

Leave ALLOW_SIGNUP set to "false" and add people from the admin area, which produces an invitation link you send them. Put everyone who shares a kitchen in the same household so they share the recipes, the meal plan and the shopping list. Separate households on one server keep separate collections.

What happens to my recipes if I stop running Mealie?

They come out. The admin backup writes your data as JSON, and Mealie can also export recipes as plain markdown files, which stay readable in any text editor with no software at all. Take one export before you need it and check that you can open it.