Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Tandoor Recipes, the recipe manager and meal planner, on a VPS with a pinned Docker image, PostgreSQL, Caddy HTTPS, the ALLOWED_HOSTS setting that trips everyone up, and tested backups.
Tandoor Recipes is a self-hosted recipe manager and meal planner. You import recipes from thousands of websites, plan several meals a day, and turn the plan into a shopping list that a whole household shares. It is a Python (Django) application with a Vue front end, backed by PostgreSQL, and published as one Docker image that bundles its own nginx.
The license is AGPL-3.0 with the Commons Clause added. The Commons Clause
removes the right to "Sell" the software, and it defines that to include offering
a paid hosting or support service whose value comes from the software. Running it
for yourself, your family or your own organization is the intended use; if you
were thinking of hosting it for paying customers, read the repository's
LICENSE.md first.
If you are choosing between recipe managers, our Mealie vs Tandoor comparison covers the difference in how much machinery each asks you to run, and Mealie has its own page. This guide uses the upstream "plain" Docker layout (PostgreSQL plus the Tandoor image), pinned to a release, with Caddy in front for HTTPS.
Our listing puts the floor at 1 GB of RAM, but plan for more. On a 2 vCPU, 8 GB test box, a few minutes after the first start and with no one using it, the Tandoor container held about 825 MB and PostgreSQL about 50 MB. The container runs the Python application server and nginx together, and the first start also runs the database migrations. A 2 GB server is the comfortable minimum, and a 1 GB server leaves nothing for the OS and Caddy on top.
Disk is small: the database is modest, and the media directory grows with the recipe photos you upload. Pick a plan with room to expand.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you are logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
On a Docker host a published container port goes around ufw. The upstream
Compose file publishes Tandoor on port 80 of every interface; the file below binds
it to 127.0.0.1 instead and leaves Caddy as the one public door.
Paid link — we earn a commission if you shop through it.
If Docker is not on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo docker compose version
Tandoor publishes a configuration template with only the required options.
Download it as your .env, then fill in the four values it needs. Set your
hostname first (this guide uses recipes.example.com; replace it with yours). The
secret key and database password are generated here and never typed:
mkdir -p ~/tandoor && cd ~/tandoor
DOMAIN=recipes.example.com
curl -fsSL --retry 5 --retry-delay 3 -o .env https://raw.githubusercontent.com/TandoorRecipes/recipes/develop/.env.template
sed -i \
-e "s|^SECRET_KEY=.*|SECRET_KEY=$(openssl rand -hex 32)|" \
-e "s|^ALLOWED_HOSTS=.*|ALLOWED_HOSTS=$DOMAIN|" \
-e "s|^TZ=.*|TZ=Etc/UTC|" \
-e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 16)|" \
.env
echo "DOMAIN=$DOMAIN" >> .env
chmod 600 .env
grep -cE '^(SECRET_KEY|POSTGRES_PASSWORD)=.{16,}' .env
The .env.template comes from the develop branch of the Tandoor repository,
which moves, while the image is pinned. To pin the template too, replace
develop in the URL with a release tag or a commit hash.
The last line should print 2. Two of these settings deserve a note:
SECRET_KEY signs sessions and other tokens. Treat it like a root password
and back it up. Change it on a running instance and everyone is logged out.ALLOWED_HOSTS must list the hostname you will use. Django refuses any
request whose Host header is not on the list, and that includes your own
curl against 127.0.0.1. See the troubleshooting section for what that looks
like.Set TZ to your own time zone if you prefer; it affects meal-plan dates. Now the
Compose file. It matches the upstream "plain" layout (PostgreSQL 16 and the
Tandoor image), with the image pinned to a release instead of the moving default
tag, the port bound to loopback, and Caddy added:
cd ~/tandoor
cat > docker-compose.yml <<'EOF'
services:
db_recipes:
image: postgres:16-alpine
restart: always
env_file:
- ./.env
volumes:
- ./postgresql:/var/lib/postgresql/data
web_recipes:
image: vabene1111/recipes:2.6.15
restart: always
env_file:
- ./.env
ports:
- "127.0.0.1:8080:80"
volumes:
- staticfiles:/opt/recipes/staticfiles
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
caddy:
image: caddy:2
environment:
DOMAIN: ${DOMAIN}
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- web_recipes
volumes:
staticfiles:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy web_recipes:80
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
Start the stack. The first start runs the database migrations, and upstream says to wait two to three minutes. Wait for the site to answer the right way rather than guessing; the check sends the real hostname, because the application refuses anything else:
cd ~/tandoor
sudo docker compose up -d
. ./.env
code=000
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: $DOMAIN" http://127.0.0.1:8080/)
[ "$code" = "302" ] && break
sleep 5
done
echo "Tandoor answered HTTP $code after $i checks"
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
A 302 is the redirect to the setup page, which means Tandoor is up and waiting
for its first user.
Create an A record for your hostname (say recipes.example.com) pointing at
the server's public IP, and confirm it resolves before Caddy tries to get a
certificate:
dig +short recipes.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:
cd ~/tandoor
. ./.env
out=000
for i in $(seq 1 24); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/accounts/login/") && [ "${out%% *}" = "200" ] && break
sleep 5
done
echo "login page, status and certificate check: $out"
[ "${out%% *}" = "200" ] || false
A second number of 0 means the certificate verified. Caddy passes the original
protocol on to Tandoor in the X-Forwarded-Proto header, which the Tandoor
documentation lists as required; if you swap Caddy for another proxy, set it
yourself.
Until the first user exists, the /setup page is open to anyone who can reach the
site, and whoever submits it first becomes the owner. Do it right after HTTPS
works. You can use the page in the browser at https://recipes.example.com/setup/,
or create the account from the command line with a generated password. The fixed
Aa1- prefix just guarantees upper-case, lower-case and a digit in case a password
policy asks for them:
cd ~/tandoor
. ./.env
( umask 077; echo "Aa1-$(openssl rand -hex 10)" > ~/tandoor/admin-password )
J=$(mktemp)
T=$(curl -s -c "$J" "https://$DOMAIN/setup/" | sed -n 's/.*name="csrfmiddlewaretoken" value="\([^"]*\)".*/\1/p')
curl -s -b "$J" -c "$J" -o /dev/null -e "https://$DOMAIN/setup/" \
--data-urlencode "csrfmiddlewaretoken=$T" -d "name=admin" \
--data-urlencode "password=$(cat ~/tandoor/admin-password)" \
--data-urlencode "password_confirm=$(cat ~/tandoor/admin-password)" \
-w 'setup: HTTP %{http_code} -> %{redirect_url}\n' "https://$DOMAIN/setup/"
curl -s -o /dev/null -w 'setup page afterwards: HTTP %{http_code} -> %{redirect_url}\n' "https://$DOMAIN/setup/"
rm -f "$J"
The first line should say 302 to the login page, and so should the second:
once a user exists, /setup redirects to the login page instead of showing the
form. Sign in as admin with the password in ~/tandoor/admin-password, then
change it from your account settings.
Now prove the whole stack works end to end by signing in and creating a recipe through the API, the same way the web interface does (it sends a CSRF token with every change):
cd ~/tandoor
. ./.env
J=$(mktemp)
T=$(curl -s -c "$J" "https://$DOMAIN/accounts/login/" | sed -n 's/.*name="csrfmiddlewaretoken" value="\([^"]*\)".*/\1/p')
curl -s -b "$J" -c "$J" -o /dev/null -e "https://$DOMAIN/accounts/login/" \
--data-urlencode "csrfmiddlewaretoken=$T" -d "login=admin" \
--data-urlencode "password=$(cat ~/tandoor/admin-password)" "https://$DOMAIN/accounts/login/"
CS=$(awk '$6=="csrftoken"{print $7}' "$J")
curl -s -b "$J" -X POST -H "X-CSRFToken: $CS" -H "Referer: https://$DOMAIN/" -H 'Content-Type: application/json' \
-d '{"name":"Guide test soup","description":"","keywords":[],"steps":[{"instruction":"Boil water.","ingredients":[]}],"working_time":5,"waiting_time":0,"servings":2}' \
"https://$DOMAIN/api/recipe/" -o /dev/null -w 'create recipe: HTTP %{http_code}\n'
curl -s -b "$J" "https://$DOMAIN/api/recipe/" | grep -o '"count":[0-9]*'
curl -s -o /dev/null -w 'anonymous API read: HTTP %{http_code}\n' "https://$DOMAIN/api/recipe/"
rm -f "$J"
Expect create recipe: HTTP 201, "count":1 and an anonymous read of 403.
After you sign in:
We confirmed the install, the setup flow, the login and the API in our own run. We did not import a recipe from the web or test the meal planner, so do those before you rely on it.
State lives in three places: the PostgreSQL database (recipes, plans, users), the
mediafiles directory (recipe images and files) and your .env. Tandoor has no
built-in backup, so your own routine is the only one. Dump the database in
PostgreSQL's custom format and archive the media and the stack files. These files
hold your data and secrets, so they are created readable by their owner only:
cd ~/tandoor
( umask 077
sudo docker compose exec -T db_recipes sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > ~/tandoor-db-$(date +%F).dump
sudo tar czf ~/tandoor-media-$(date +%F).tar.gz -C ~/tandoor mediafiles
tar czf ~/tandoor-stack-$(date +%F).tar.gz docker-compose.yml Caddyfile .env )
ls -lh ~/tandoor-db-*.dump ~/tandoor-media-*.tar.gz ~/tandoor-stack-*.tar.gz
Copy these off the server and run them from cron. A backup you have never restored is a guess, so prove the dump loads. This restores it into a throwaway PostgreSQL container and compares the recipe count with the live database:
cd ~/tandoor
sudo docker rm -f tandoor-restore-test >/dev/null 2>&1
sudo docker run -d --name tandoor-restore-test -e POSTGRES_PASSWORD=restore-test -e POSTGRES_DB=djangodb postgres:16-alpine >/dev/null
for i in $(seq 1 40); do
sudo docker exec tandoor-restore-test pg_isready -U postgres -d djangodb >/dev/null 2>&1 && break
sleep 2
done
sleep 3
sudo docker exec -i tandoor-restore-test pg_restore -U postgres -d djangodb --no-owner < ~/tandoor-db-$(date +%F).dump
LIVE=$(sudo docker compose exec -T db_recipes sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "select count(*) from cookbook_recipe"')
RESTORED=$(sudo docker exec tandoor-restore-test psql -U postgres -d djangodb -tAc "select count(*) from cookbook_recipe")
echo "recipes live: $LIVE, restored: $RESTORED"
sudo docker rm -f tandoor-restore-test >/dev/null
[ "$LIVE" = "$RESTORED" ] && [ "$LIVE" -ge 1 ]
The image tag is the version, and Tandoor's documentation is blunt that there is
no downgrading: the database has no way to migrate back to an older release.
So back up first (the previous section), then change the tag in
docker-compose.yml to the release you want:
cd ~/tandoor
sed -i 's|vabene1111/recipes:2.6.15|vabene1111/recipes:NEW_VERSION|' docker-compose.yml
Then pull and recreate, and give the migrations a few minutes:
cd ~/tandoor
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
If an upgrade goes wrong, the way back is a restore: put the old tag back in
docker-compose.yml and restore the pre-upgrade database dump into a fresh
PostgreSQL data directory. Read the
release notes before you
move.
Every request returns 400 Bad Request and the log says Invalid HTTP_HOST header. This is ALLOWED_HOSTS. Django rejects any request whose Host header
is not on the list, so a plain curl http://127.0.0.1:8080/ fails even though the
site is healthy; we hit it on our first check. Send the real name with
curl -H "Host: recipes.example.com" http://127.0.0.1:8080/, and if a real
visitor sees the error, fix ALLOWED_HOSTS in .env and run
sudo docker compose up -d.
The site is blank or errors for the first minutes. The first start runs the
database migrations, and upstream says to allow two to three minutes. Watch
sudo docker compose logs web_recipes --tail 30 and wait for requests to be
served.
Logins or forms fail with a CSRF error behind the proxy. Tandoor's FAQ puts this
down to a reverse proxy not passing the right headers. The proxy must keep the
Host header and send X-Forwarded-Proto; Caddy does both by default, so this
mostly bites when you swap in another proxy. We did not hit it in our run.
The container is killed or the server swaps heavily. Memory. The Tandoor
container alone used over 800 MB in our run, so check free -m and
sudo docker stats --no-stream and move to a larger plan.
Caddy never gets a certificate. Confirm dig +short recipes.example.com
returns the server's IP and that your provider's network firewall (not just
ufw) allows inbound 80 and 443.
You are done when you can: load https://recipes.example.com with a valid
certificate, sign in as the account you created, see /setup redirect away from
the open form, create and read back a recipe, and restore a database dump into a
throwaway container, as above.
From here, import your first recipes, invite your household, and set up a regular backup copy off the server. For ranked server options, see Best VPS for self-hosting.