Production deployment¶
This page explains how to put Cocotte in production on your own server, from an empty machine to a working HTTPS site with an administrator account and reference data.
There are three supported ways to run it:
| Variant | When to use it | What handles HTTPS |
|---|---|---|
| Docker (standalone, the default) | Cocotte is the only web application on the server. | The bundled Caddy inside the frontend container, with automatic Let's Encrypt certificates. |
| Docker behind a shared proxy | The server already runs a host-level Caddy shared by several applications. | The host Caddy. Cocotte's own Caddy only speaks plain HTTP on an internal Docker network. |
| Classic (no Docker) | You prefer system packages and services (PostgreSQL, systemd, a host web server). | Your web server (Caddy, or nginx with Certbot). |
Tip
If you only want to try Cocotte on your own computer, you don't need any of this: see Installation instead.
How requests flow¶
Whatever the variant, the browser only talks to one web server. That server serves the built Vue application (a single-page app), serves static and uploaded files directly, and forwards API and Django admin requests to Gunicorn, which runs the Django backend.
| Path | Served by |
|---|---|
/api/* |
Django backend (Gunicorn) |
/django-admin/* |
Django backend (Gunicorn) |
/static/* |
Files collected by collectstatic (Django admin CSS/JS) |
/media/* |
Uploaded files (recipe images, thematic page images) |
| everything else | The built SPA; unknown paths fall back to index.html |
flowchart LR
subgraph Standalone["Docker (standalone)"]
B1[Browser] -- "HTTPS :443" --> F1["frontend container<br/>Caddy + SPA<br/>(TLS, Let's Encrypt)"]
F1 -- "/api, /django-admin<br/>HTTP :8000" --> G1["backend container<br/>Gunicorn + Django"]
G1 --> P1[("db container<br/>PostgreSQL 16")]
end
flowchart LR
subgraph Shared["Docker behind a shared proxy"]
B2[Browser] -- "HTTPS :443" --> H2["Host Caddy<br/>(TLS for every app)"]
H2 -- "HTTP, external network<br/>cocotte-frontend:80" --> F2["frontend container<br/>Caddy + SPA"]
F2 -- "/api, /django-admin<br/>HTTP :8000" --> G2["backend container<br/>Gunicorn + Django"]
G2 --> P2[("db container<br/>PostgreSQL 16")]
end
Prerequisites¶
All variants need:
- A Linux server with a public IP address.
- A domain name (for example
cocotte.example.org) whose DNSA(andAAAAif you use IPv6) record points at the server. Create it before the first start: Let's Encrypt can only issue a certificate once the name resolves to your server. - Ports 80 and 443 open in the firewall. Port 80 is needed for the Let's Encrypt HTTP challenge and for the HTTP → HTTPS redirect.
- Outgoing HTTPS access from the server: recipe import from a URL, the Suggest a free image button of the recipe form (Openverse), and the Suggest values button of the ingredient form (Open Food Facts and Agribalyse), call external websites.
- An SMTP account to send password-reset emails (see Configuration).
Per variant:
| Variant | Additional requirements |
|---|---|
| Docker | Docker Engine and the Docker Compose plugin (docker compose), Git. |
| Docker behind a shared proxy | The same, plus an existing Caddy stack on the host and an external Docker network it is attached to. |
| Classic | PostgreSQL (the Docker variant uses version 16), Python 3.11 or later, uv, Node.js (the Docker build uses Node 22) and npm, the WeasyPrint system libraries, Caddy or nginx, systemd. |
Install Cocotte¶
The procedure differs per variant. Pick the tab that matches your setup; the following sections (first administrator and reference data, post-install checklist) apply to all of them.
This is the default mode of docker-compose.prod.yml. It starts three containers:
db: PostgreSQL 16 (postgres:16-alpine), databasecocotte, userpostgres.backend: the Django API served by Gunicorn (3 workers), built frombackend/Dockerfile(prodtarget).-
frontend: Caddy serving the built SPA, reverse-proxying/apiand/django-admintobackend, serving/staticand/mediafrom shared volumes, and terminating TLS. It publishes ports80and443and obtains and renews its own Let's Encrypt certificate forDOMAIN. -
Clone the repository on the server, for example in
/opt/cocotte: -
Create the production environment file from the template:
-
Edit
.env.prod. At minimum set:Variable Example DOMAINcocotte.example.orgACME_EMAILadmin@example.orgDJANGO_SECRET_KEYa long random value (see Generate a secret key) DJANGO_ALLOWED_HOSTScocotte.example.orgCORS_ALLOWED_ORIGINShttps://cocotte.example.orgCSRF_TRUSTED_ORIGINShttps://cocotte.example.orgPOSTGRES_PASSWORDa strong password DATABASE_URLpostgres://postgres:<same password>@db:5432/cocotteFRONTEND_URLhttps://cocotte.example.orgEMAIL_*,DEFAULT_FROM_EMAILyour SMTP settings Every variable is described in Configuration.
-
Build and start the stack:
-
Follow the logs until Caddy reports that it obtained the certificate and Gunicorn is listening:
The certificate is stored in the caddy_data volume, so it survives restarts and rebuilds.
Important
POSTGRES_PASSWORD is only applied when the db container initialises an empty data
volume. The password embedded in DATABASE_URL must be identical to it. Changing
POSTGRES_PASSWORD later does not change the password of the existing database.
Warning
.env.prod is not listed in .gitignore. Never commit it: it contains your secret key and
passwords.
Use this mode when a single Caddy instance, running in its own stack on the host, owns ports 80
and 443 and handles HTTPS for every application on the server. The
docker-compose.prod.proxy.yml overlay changes the frontend service so that it:
- no longer publishes any port;
- mounts
deploy/Caddyfile.proxy(plain HTTP on:80, no TLS) instead ofdeploy/Caddyfile.standalone; - joins an external Docker network, named by
PROXY_NETWORK_NAME(defaultproxy), on which the host Caddy reaches it ascocotte-frontend.
Because TLS is terminated by the host Caddy, deploy/Caddyfile.proxy always forwards
X-Forwarded-Proto: https to Django. Without it, Django's HTTPS redirect would answer every
request with a redirect loop.
-
Create the shared network, once per server (skip this if your host Caddy already uses one):
Make sure the host Caddy container is attached to this network.
-
Add a site block for Cocotte to the host Caddy's
Caddyfile(for example/opt/docker/caddy/Caddyfile):Then reload the host Caddy, from the host Caddy stack's directory:
The service name (
caddy) and the Caddyfile path depend on how your host Caddy stack is set up. -
Clone Cocotte in its own directory, for example
/opt/docker/cocotte, and create the environment file: -
Edit
.env.prodas in theDockertab, with these differences:- Set
PROXY_NETWORK_NAMEto the name of the network from step 1 (defaultproxy). DOMAINandACME_EMAILare unused: the host Caddyfile declares the domain and manages the certificate.DJANGO_ALLOWED_HOSTS,CORS_ALLOWED_ORIGINS,CSRF_TRUSTED_ORIGINSandFRONTEND_URLmust still match the public domain declared in the host Caddyfile.
- Set
-
Build and start the stack with both compose files:
Important
In this mode, every docker compose command shown in this guide must include
-f docker-compose.prod.proxy.yml after -f docker-compose.prod.yml. Otherwise Compose
would recreate frontend in standalone mode and try to publish ports 80 and 443.
Note
The frontend container has a fixed name, cocotte-frontend. You can therefore run only one
Cocotte stack per Docker host without editing the compose file.
This variant runs the same components without Docker: PostgreSQL from your distribution, the
Django backend under Gunicorn managed by systemd, and the built SPA served by a web server that
also reverse-proxies the backend. The commands below target Debian/Ubuntu and assume the code
lives in /opt/cocotte and runs as a dedicated cocotte system user; adapt paths and package
names to your system.
1. Install system packages
sudo apt update
sudo apt install -y git postgresql \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 libglib2.0-0 \
shared-mime-info fonts-liberation
The second line is the set of libraries WeasyPrint needs to render the recipe and week PDFs. It
is the same list as in backend/Dockerfile, which additionally installs libpq-dev and gcc;
those are only needed if the PostgreSQL driver has to be compiled, which the psycopg[binary]
dependency normally avoids.
Also install:
- uv, installed somewhere on every
user's
PATH(for example/usr/local/bin) so that thecocotteuser can run it. uv downloads Python 3.11+ itself if your system Python is older; - Node.js 22 and npm (for example from NodeSource or your distribution);
- your web server: Caddy (recommended, automatic HTTPS) or nginx with Certbot.
2. Create the database
The first migration enables the unaccent and pg_trgm PostgreSQL extensions (used for accent-
and typo-tolerant ingredient search). Making the cocotte role the owner of the database is
what allows it to create them (both are "trusted" extensions since PostgreSQL 13). On an older
PostgreSQL, create them once as a superuser:
sudo -u postgres psql cocotte -c 'CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS pg_trgm;'
3. Get the code and install the backend
sudo useradd --system --create-home --home-dir /opt/cocotte --shell /usr/sbin/nologin cocotte
sudo -u cocotte git clone https://github.com/jacquesfize/cocotteapp.git /opt/cocotte/app
In the rest of this tab, /opt/cocotte/app is the repository root. Install the Python
dependencies without the development group and with the prod extra (which adds Gunicorn),
exactly like the production Docker image does:
This creates the virtual environment in backend/.venv.
4. Configure the backend
Django reads backend/.env automatically. Create it from the development template and edit it:
Set at least the following (see Configuration for every variable):
DJANGO_SECRET_KEY=<long random value>
DJANGO_ALLOWED_HOSTS=cocotte.example.org
CORS_ALLOWED_ORIGINS=https://cocotte.example.org
CSRF_TRUSTED_ORIGINS=https://cocotte.example.org
DATABASE_URL=postgres://cocotte:<password>@localhost:5432/cocotte
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.org
EMAIL_PORT=587
EMAIL_HOST_USER=<user>
EMAIL_HOST_PASSWORD=<password>
EMAIL_USE_TLS=True
DEFAULT_FROM_EMAIL=Cocotte <noreply@cocotte.example.org>
FRONTEND_URL=https://cocotte.example.org
Remove the DEBUG=True line copied from the template (it is ignored by the production settings
anyway, but it is misleading).
Important
DJANGO_SETTINGS_MODULE cannot go in backend/.env: the settings module is chosen before that
file is read. manage.py defaults to the development settings (config.settings.dev,
DEBUG=True), while Gunicorn's config.wsgi defaults to config.settings.prod. Always pass
it to management commands in production. This guide runs them as the cocotte user (who owns
backend/.env, readable by nobody else) with:
5. Initialise the database and static files
These are the two commands the Docker image's entrypoint runs automatically:
cd /opt/cocotte/app/backend
sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py migrate --noinput
sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py collectstatic --noinput
collectstatic writes to backend/staticfiles/; uploaded files go to backend/media/.
Note
Production commands in this guide call the virtual environment's interpreter
(.venv/bin/python) directly. uv run also works, but it re-syncs the environment with the
default dependency groups, which re-installs the development tools on the server.
6. Run Gunicorn with systemd
Create /etc/systemd/system/cocotte.service. The command mirrors the one in
backend/Dockerfile, but binds to the loopback interface only:
[Unit]
Description=Cocotte backend (Gunicorn)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
User=cocotte
Group=cocotte
WorkingDirectory=/opt/cocotte/app/backend
Environment=DJANGO_SETTINGS_MODULE=config.settings.prod
ExecStart=/opt/cocotte/app/backend/.venv/bin/gunicorn config.wsgi:application \
--bind 127.0.0.1:8000 --workers 3 --access-logfile - --error-logfile -
Restart=on-failure
[Install]
WantedBy=multi-user.target
Then enable and start it:
7. Build the frontend
The build output (including the PWA service worker) is written to frontend/dist/. The frontend
calls the API on the same origin (/api/), so there is nothing to configure at build time.
8. Configure the web server
The web server must reproduce deploy/Caddyfile.standalone: serve /static/ and /media/ from
disk, send /api/ and /django-admin/ to Gunicorn, and serve the SPA with a fallback to
index.html for every other path. The web server user needs read access to frontend/dist,
backend/staticfiles and backend/media.
With Caddy (automatic HTTPS), /etc/caddy/Caddyfile:
{
email admin@example.org
}
cocotte.example.org {
encode gzip
handle_path /static/* {
root * /opt/cocotte/app/backend/staticfiles
file_server
}
handle_path /media/* {
root * /opt/cocotte/app/backend/media
file_server
}
@backend path /api/* /django-admin /django-admin/*
handle @backend {
reverse_proxy 127.0.0.1:8000
}
handle {
root * /opt/cocotte/app/frontend/dist
try_files {path} /index.html
file_server
}
}
Caddy sets the X-Forwarded-Proto header that Django relies on to know the request arrived over
HTTPS.
With nginx, obtain a certificate with Certbot (sudo certbot certonly --nginx -d
cocotte.example.org, or your usual method), then create
/etc/nginx/sites-available/cocotte and enable it:
server {
listen 80;
listen [::]:80;
server_name cocotte.example.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name cocotte.example.org;
ssl_certificate /etc/letsencrypt/live/cocotte.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cocotte.example.org/privkey.pem;
# nginx refuses request bodies over 1 MB by default: raise it for image uploads
# and recipe archive imports.
client_max_body_size 50m;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
root /opt/cocotte/app/frontend/dist;
location /static/ {
alias /opt/cocotte/app/backend/staticfiles/;
}
location /media/ {
alias /opt/cocotte/app/backend/media/;
}
location ~ ^/(api/|django-admin(/|$)) {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
sudo ln -s /etc/nginx/sites-available/cocotte /etc/nginx/sites-enabled/cocotte
sudo nginx -t && sudo systemctl reload nginx
Important
Whatever the web server, it must forward the original Host header and set
X-Forwarded-Proto: https. Without X-Forwarded-Proto, Django (which redirects HTTP to HTTPS
in production) redirects every API request in a loop.
Create the first administrator and load reference data¶
The Docker image runs migrate and collectstatic automatically every time the backend
container starts with Gunicorn (see backend/docker-entrypoint.sh); in the Classic variant you
ran them in step 5. What remains, for every variant, is creating an administrator account and
loading the reference data.
The database starts empty. The seed data is what makes nutrition alerts, carbon footprints, allergens and the homepage shortcuts work, so load it before opening the site to users.
-
Create a superuser. You are asked for an email address (used to log in), a username (the name displayed in the app) and a password.
See Users for a non-interactive variant.
-
Load the reference data, in this order:
docker compose -f docker-compose.prod.yml --env-file .env.prod exec backend python manage.py seed_allergens docker compose -f docker-compose.prod.yml --env-file .env.prod exec backend python manage.py seed_common_ingredients docker compose -f docker-compose.prod.yml --env-file .env.prod exec backend python manage.py seed_cookware docker compose -f docker-compose.prod.yml --env-file .env.prod exec backend python manage.py seed_nutrient_requirements docker compose -f docker-compose.prod.yml --env-file .env.prod exec backend python manage.py seed_thematic_pagesdocker compose -f docker-compose.prod.yml -f docker-compose.prod.proxy.yml --env-file .env.prod exec backend python manage.py seed_allergens docker compose -f docker-compose.prod.yml -f docker-compose.prod.proxy.yml --env-file .env.prod exec backend python manage.py seed_common_ingredients docker compose -f docker-compose.prod.yml -f docker-compose.prod.proxy.yml --env-file .env.prod exec backend python manage.py seed_cookware docker compose -f docker-compose.prod.yml -f docker-compose.prod.proxy.yml --env-file .env.prod exec backend python manage.py seed_nutrient_requirements docker compose -f docker-compose.prod.yml -f docker-compose.prod.proxy.yml --env-file .env.prod exec backend python manage.py seed_thematic_pagescd /opt/cocotte/app/backend sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py seed_allergens sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py seed_common_ingredients sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py seed_cookware sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py seed_nutrient_requirements sudo -u cocotte env DJANGO_SETTINGS_MODULE=config.settings.prod .venv/bin/python manage.py seed_thematic_pages
| Command | What it loads |
|---|---|
seed_allergens |
The reference allergen list (the 14 EU allergens plus lactose), used by user profiles and the ingredient form. |
seed_common_ingredients |
About 170 common ingredients with nutrition values, carbon footprint, seasonality, English/German/Spanish names and reviewed allergens. It also runs seed_allergens itself, so the allergen list is guaranteed even if you skipped the first command. |
seed_cookware |
About 60 common cookware items (oven, air fryer, pans, dishes, tools) with their English names, emojis and photos downloaded from Wikimedia Commons (needs Internet access; --skip-images to skip them), offered in the recipe form and the Cookware filter. |
seed_nutrient_requirements |
Daily minimum intakes (protein, iron, vitamin B12, calcium, omega-3, zinc) for each diet type and activity level. Without them, the planner shows no deficiency alerts. |
seed_thematic_pages |
Three homepage shortcuts: seasonal produce, vegan, and ready in 30 minutes, with their images. |
Besides seeding, two commands should run on a schedule: purge_inactive_users (see
GDPR compliance) and purge_audit_logs (see
Moderation log).
All seed commands are idempotent: running them again never creates duplicates. They do, however, reset the records they manage to their seeded values; see Re-run the seed commands before re-running them on a live site.
Post-install checklist¶
Work through this list once the stack is up.
- HTTPS works. Open
https://cocotte.example.org/in a browser: the certificate must be valid, andhttp://must redirect tohttps://. -
The API answers. The health endpoint returns
{"status": "ok"}: -
Django admin loads with its styles. Open
https://cocotte.example.org/django-admin/and log in with the superuser's email and password. Missing styles mean/static/is not served (checkcollectstaticand the web server configuration). - Staff pages are visible. Log in to the app with the same account: the account menu shows an Administration section with Admin, Thematic pages and Ingredients.
- Reference data is loaded. Ingredients lists the seeded ingredients, and the homepage shows the thematic shortcuts under Explore.
- Emails are sent. Log out, click Forgot your password? on the login page, and request
a reset for your address. The email must arrive, and its link must start with your public URL
(it is built from
FRONTEND_URL). If nothing arrives, check the backend logs (see Maintenance): with the default console backend, the email is only printed there. - PDF export works. Open a recipe and click Download as PDF. A failure here usually means missing WeasyPrint libraries (Classic variant).
- Backups are scheduled. See Back up and restore.
Note
In the Docker variants, docker compose ps may report the backend container as
unhealthy. Its built-in healthcheck calls http://localhost:8000/api/health/ from inside the
container, which the production settings reject (localhost is not in DJANGO_ALLOWED_HOSTS,
and plain-HTTP requests are redirected to HTTPS). Nothing depends on this status; check health
through the public URL as shown above.
Next steps¶
- Review every setting in Configuration.
- Plan updates and backups with Maintenance.
- Manage accounts in Users and curate the Ingredient library.