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 name | 1 to 20 users, limited concurrent writes | Many concurrent users |
| Features | Everything except the Postgres-only ones | Adds fuzzy search |
| Containers | 1 | 2 (Mealie plus postgres:17) |
| Where the data sits | All of it in /app/data | Tables in their own volume, files in /app/data |
| Network-attached storage | Not suitable: corruption or locked-database errors | The 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
- Put a current Ubuntu or Debian on the server, then add Docker Engine at version 28 or above, with the Compose plugin;
docker versionshows 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). - As root, turn ufw on with SSH allowed before any container starts:
apt-get install -y ufw, thensed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufwso every rule gets an IPv6 copy, thenufw allow OpenSSHandufw --force enable. - Create the folder and a private credentials file:
mkdir -p /opt/mealie && cd /opt/mealie,install -m 600 /dev/null db.env, thenecho POSTGRES_USER=mealie >> db.env,echo POSTGRES_DB=mealie >> db.envandecho "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> db.env.ls -l db.envshould show-rw-------owned by root. - Write the compose file that follows to
/opt/mealie/docker-compose.yaml, replacing the image tag with the current release from the README badge andBASE_URLwith the address you will publish. - Bring the stack up (
docker compose up -d) and confirm thatss -ltnp | grep 9925shows127.0.0.1:9925and nothing on0.0.0.0or[::].
# /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.envthroughenv_fileinstead of sitting in the file. - The port is bound to
127.0.0.1rather than to every address. - The
postgresservice has nocontainer_name, because Mealie reaches it by its service name. - The
mealiecontainer keeps its name, so the FAQ'sdocker exec -it mealiecommands 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_SIGNUPisfalseby 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_TIMEis 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.