Use case · Recipe manager

Mealie self hosted on a dedicated server: Compose, first login, backups

Self-hosting Mealie means running one container from ghcr.io/mealie-recipes/mealie, with SQLite or a PostgreSQL container beside it, published on loopback until the default admin login has been replaced. This page sets it up with Docker Compose, puts it behind a TLS proxy, schedules the built-in backup and covers updates.

See the machines

What runs on the box

Mealie is a recipe manager and meal planner: recipes imported from a URL or typed in, meal plans, shopping lists, cookbooks, households and groups. Current releases ship as a single image whose container serves both the frontend and the API on port 9000. Images are built for linux/amd64 and linux/arm64; there is no 32-bit ARM build.

Everything Mealie writes lives under /app/data in the container: the SQLite file when SQLite is the engine, recipe images, user files, templates and the backups folder. With DB_ENGINE: postgres the tables move to a PostgreSQL container and /app/data keeps the files. The code is AGPL-3.0. Our general reading of it, not legal advice: running it unmodified, or running a modified copy that only you use, carries no duty. Handing copies to other people, modified or not, means passing the source on with them. Changed code that other people use over the network means offering those users its source.

Sizing: SQLite or Postgres

The project publishes no CPU or core figures. What it does state is which database fits which load, and that the container should run under an explicit memory limit: its compose files set memory: 1000M, because Python can reserve far more memory than it needs on a host with plenty of RAM and then idle high.

SQLite (default)PostgreSQL
Load the docs name1 to 20 users, limited concurrent writesMany concurrent users
FeaturesEverything except the Postgres-only onesAdds fuzzy search
Containers12 (Mealie plus postgres:17)
Where the data sitsAll of it in /app/dataTables in their own volume, files in /app/data
Network-attached storageNot suitable: corruption or locked-database errorsThe docs' choice when NAS is involved

You are not locked in. The backup format is the same for both engines, so the docs describe moving between SQLite and Postgres with a backup and a restore. The rest of this page uses Postgres, with the password kept out of the compose file.

The machine

Mealie is one service with a small database, the one-service-per-machine job the dedicated server range assigns to its Moonshot cartridges. A single-socket line suits it when the same box also carries Paperless-ngx, a wiki or the other services a household or office self-hosts. Either way it is the whole machine with root and no other tenant. Choose an x86 cartridge: the X-Gene ARM cartridge installs only Ubuntu 16.04 and 18.04, which Docker Engine 28 does not support.

Two details matter more than cores here. Rack servers carry an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges; the other cartridges have power control only. The console is what gets you back in when a bad firewall rule locks SSH out. IPv6 is not in the default build; it is added free at checkout, so the firewall steps below cover both address families.

Install with Docker Compose

  1. Put a current Ubuntu or Debian on the server, then add Docker Engine at version 28 or above, with the Compose plugin; docker version shows the Engine release. Version 28 matters: from that release a port published on 127.0.0.1 is reachable from the host only (moby#45610).
  2. As root, turn ufw on with SSH allowed before any container starts: apt-get install -y ufw, then sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw so every rule gets an IPv6 copy, then ufw allow OpenSSH and ufw --force enable.
  3. Create the folder and a private credentials file: mkdir -p /opt/mealie && cd /opt/mealie, install -m 600 /dev/null db.env, then echo POSTGRES_USER=mealie >> db.env, echo POSTGRES_DB=mealie >> db.env and echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> db.env. ls -l db.env should show -rw------- owned by root.
  4. Write the compose file that follows to /opt/mealie/docker-compose.yaml, replacing the image tag with the current release from the README badge and BASE_URL with the address you will publish.
  5. Bring the stack up (docker compose up -d) and confirm that ss -ltnp | grep 9925 shows 127.0.0.1:9925 and nothing on 0.0.0.0 or [::].
# /opt/mealie/docker-compose.yaml
services:
  mealie:
    image: ghcr.io/mealie-recipes/mealie:v3.29.0
    container_name: mealie
    restart: always
    ports:
      - "127.0.0.1:9925:9000"
    deploy:
      resources:
        limits:
          memory: 1000M
    volumes:
      - mealie-data:/app/data/
    env_file: db.env
    environment:
      ALLOW_SIGNUP: "false"
      PUID: 1000
      PGID: 1000
      TZ: America/New_York
      BASE_URL: https://recipes.example.com
      DB_ENGINE: postgres
      POSTGRES_SERVER: postgres
      POSTGRES_PORT: 5432
    depends_on:
      postgres:
        condition: service_healthy
  postgres:
    image: postgres:17
    restart: always
    volumes:
      - mealie-pgdata:/var/lib/postgresql/data
    env_file: db.env
    environment:
      PGUSER: mealie
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 30s
      timeout: 20s
      retries: 3

volumes:
  mealie-data:
  mealie-pgdata:
  • Where this file departs from the docs' Postgres example: the credentials come from db.env through env_file instead of sitting in the file.
  • The port is bound to 127.0.0.1 rather than to every address.
  • The postgres service has no container_name, because Mealie reaches it by its service name.
  • The mealie container keeps its name, so the FAQ's docker exec -it mealie commands still work.

Why the 127.0.0.1: prefix: a port mapping with no address is published on every host address, 0.0.0.0 and [::]. Published traffic skips ufw's rules on IPv4, and on IPv6 too when Docker's own IPv6 networking is enabled; when it is not, the [::] socket belongs to docker-proxy and ufw filters it. Binding to loopback avoids depending on any of that. The Postgres container publishes no port at all.

Replacing the default admin before anything is public

A new instance has a working admin account with published credentials: changeme@example.com with the password MyPassword. Whoever gets to the login form before you change them becomes an administrator. So the first login happens through an SSH tunnel to the loopback port, using your own login on the server: run ssh -N -L 9925:127.0.0.1:9925 you@your-server on your computer and open http://localhost:9925.

After that first sign-in Mealie walks through setup screens that ask for your own account details, including a new password; fill them in. If you skip them, change the email and password from your user profile before doing anything else. Then open /admin/site-settings, which lists each configuration check and flags anything wrong.

  • ALLOW_SIGNUP is false by default since v1.4.0 and stays false here. Add people with invitation links, which needs the Invite permission, or from User Management at /admin/manage/users.
  • Groups and households are private by default. A recipe is visible without a login only when the group is not private, the household is not private and has its setting for letting users outside the group see recipes turned on, and the recipe itself is public. The exception is a private link made with a recipe's Share button, which works without a login.
  • Five wrong passwords lock an account for 24 hours (SECURITY_MAX_LOGIN_ATTEMPTS, SECURITY_USER_LOCKOUT_TIME); TOKEN_TIME is 48 hours, so you sign in again after 48 hours without using Mealie, while a session in use keeps refreshing.
  • Email is optional. Without SMTP settings there are no emailed invitations or password resets, and the FAQ says both can be done other ways.

Reverse proxy, BASE_URL and HOST_IP

Run the TLS proxy on the host: Caddy with a two-line Caddyfile (the site name, then reverse_proxy 127.0.0.1:9925), or nginx, or the Nginx Proxy Manager you already run. A proxy on the host is an ordinary process, so ufw blocks it on IPv4 and IPv6 alike until you run ufw allow 80,443/tcp. Port 9925 stays closed. Give Mealie a hostname of its own: the FAQ says it cannot be served on a subpath.

BASE_URL (default http://localhost:8080) is the address Mealie writes into notifications and uses for the OIDC callback, so set it to the public https:// name. HOST_IP decides which connecting addresses may set X-Forwarded-For and X-Forwarded-Proto; its default * trusts any source, which the docs accept only when nothing but trusted proxies can reach the app. The loopback binding narrows access to processes on the host and to containers in the same stack, so keep the host to yourself, or set HOST_IP to the address the proxy connects from.

Recipe import fetches whatever URL a user submits, from your server. The security page notes that the API has no rate limit by default and suggests strict limits at the proxy on /api/recipes/create/url, /api/recipes/create/ai and /api/recipes/{id}/image. Closed sign-ups keep that power with people you invited.

Optional AI features

Nothing AI-related runs until a default provider is set in the group settings. Mealie talks to any OpenAI-compatible API: an OpenAI key, or a self-hosted endpoint such as Ollama, for which the docs set base_url to the server's /v1 path and still require an API key, even though Ollama ignores it. With a provider set you get the AI ingredient parser, an AI fallback when a page has no readable recipe data, and Import with AI from pasted text, photos (needs an image-capable model) or a cooking video (needs an audio transcription model).

Whatever you import that way leaves the server for the configured provider, so choose it with that in mind. To keep that traffic on hardware you control, LLM hosting covers machines that run models of their own.

Nightly backups with the built-in feature

A backup from Admin Settings › Backups (/admin/backups) is one zip holding database.json, a dump of every table from whichever engine you run, plus the files in /app/data such as recipe images (earlier backups and the SQLite file itself are left out). The docs FAQ still speaks of automatic daily backups, but v3.29.0 registers no scheduled backup task: a backup exists only when someone asks for one. The same request is POST /api/admin/backups, which is why cron makes one each night here. Create a long-lived API token for an admin user at /user/profile/api-tokens (tick Show advanced features on the profile page if the page is hidden). Write it as one line, Authorization: Bearer followed by the token, into /etc/mealie/api-header; create that file first with install -d -m 700 /etc/mealie and install -m 600 /dev/null /etc/mealie/api-header. Curl reads the header from the file, so the token never appears in ps or in the crontab. Create the target and log folders before the first run: install -d -m 700 /var/backups/mealie /var/log/mealie-backup.

# /usr/local/sbin/mealie-backup.sh
set -euo pipefail
curl -fsS -X POST -H @/etc/mealie/api-header http://127.0.0.1:9925/api/admin/backups > /dev/null
mv /var/lib/docker/volumes/mealie_mealie-data/_data/backups/mealie_*.zip /var/backups/mealie/
find /var/backups/mealie -name 'mealie_*.zip' -mtime +14 -delete

Because of set -euo pipefail, a failed API call stops the script before the move and before the two-week prune, so old copies are never deleted on a night that produced no new one. The cron entry goes in its own file:

# /etc/cron.d/mealie-backup
40 3 * * * root umask 077; bash /usr/local/sbin/mealie-backup.sh > /var/log/mealie-backup/last.log 2>&1

The volume path assumes Docker's default data root and a Compose project named after /opt/mealie; docker volume inspect mealie_mealie-data prints the real one. Moving the zips out keeps the volume from growing, and the script keeps two weeks. A successful run prints nothing, so a non-empty last.log means that night's backup failed. That copy is still on the same disks, so pull /var/backups/mealie to another machine as an account that can read it. A restore wipes the database and replaces it with the backup, logs everyone out, and must go into the Mealie version named in the zip's file name. Restart the container if you cannot sign in afterwards.

Updates and when not to do this

The project advises a pinned version tag over latest, updated deliberately after reading the release notes, and a backup downloaded from the UI before every upgrade. The sequence is docker compose down, edit the tag, docker compose pull, docker compose up -d. Leave postgres:17 where it is: a Postgres major version change needs manual steps of its own.

A household that only wants a recipe list does not need a machine of its own; this setup earns the hardware when Mealie sits beside other services you run, or when you want the AI and import traffic on a box nobody else shares. Mealie also runs as one instance with one database, so redundancy comes from the off-machine backups, not from a second node.

Questions

What are Mealie's default login details?

The current docs give changeme@example.com with the password MyPassword; older releases used a different username. Change both on the first sign-in, and do that sign-in through an SSH tunnel to a port bound to 127.0.0.1 so the login form is yours alone until then.

Should a new Mealie install use SQLite or Postgres?

SQLite is the default and the docs call it the right fit for 1 to 20 users with limited concurrent writes. Postgres is for many concurrent users and is the only engine with fuzzy search. Backups restore into either engine, so you can start on SQLite and move later.

Does Mealie make backups on its own?

Not in current releases. The FAQ still mentions automatic daily backups, but v3.29.0 schedules no backup task; a backup is created when someone presses the button on the backups page or calls POST /api/admin/backups. Schedule that call with cron, as above, and copy the zips to another machine; the docs say a backup only protects you once it is stored somewhere other than the server.

What does BASE_URL change in Mealie?

It is the address Mealie puts in notifications and uses as the OIDC callback, and it defaults to http://localhost:8080. Set it to the public https:// address your proxy serves. It does not stop you signing in through the tunnel at localhost:9925.

Is the OpenAI key required?

No. Mealie runs without any AI provider; only the AI parser and the AI import features need one. Any OpenAI-compatible API works, including a self-hosted model server.

Which port does the Mealie container listen on?

Port 9000 inside the container, for both the UI and the API. The official compose file maps it to 9925 on the host; here that mapping is 127.0.0.1:9925:9000, and only the reverse proxy is public.

Give Mealie a machine of its own

Pick an x86 cartridge or a single-socket line, reinstall it with an up-to-date Ubuntu or Debian, and run the compose file above.

See the machines

Related on Server Room

Other use cases