Deploying¶
The production stack runs on one machine with Docker. It's written for a VPS where a Caddy container already handles HTTPS for other sites; for a server without one there is an optional bundled Caddy.
| Service | What it does |
|---|---|
web |
gunicorn on port 8000; applies migrations on start |
worker |
Celery worker: fetches feeds and articles, sends digests |
beat |
Celery beat: starts the jobs scheduled in the admin |
db |
PostgreSQL 17 (data in the postgres_data volume) |
redis |
Celery's message broker and the cache |
backup |
Daily pg_dump into ./backups, keeps the newest 14 |
Nothing is reachable from the internet except through your proxy: no port is
published on the host. Caddy reaches web on its own Docker network,
curated-search-proxy, which nothing else joins. That matters, because the
app trusts the proxy's X-Forwarded-Proto header to know a request was HTTPS.
db and redis are only on curated-search_backend, an internal network
with no route out; web, worker and beat join it and the default
network, through which their own internet traffic leaves. Redis requires a
password (REDIS_PASSWORD), because the Django cache stores pickled values.
web, worker and beat run as an unprivileged user on a read-only
filesystem (only /tmp is writable, in memory), with no Linux capabilities
and no-new-privileges, so a compromised process can't change the code or
gain privileges.
1 GB of RAM is enough for a curated set of feeds.
First deploy (behind the Caddy container already on the server)¶
- Point the domain's DNS (A/AAAA record) at the server.
- Install Docker Engine with the compose plugin
(Ubuntu instructions).
The repository and its image are private, so the server needs read
access to both. Create a GitHub personal access token (classic) with the
repoandread:packagesscopes, or a fine-grained token with read access to this repository's contents and packages, then:
echo "<token>" | docker login ghcr.io -u Joeriksson --password-stdin
git clone https://github.com/Joeriksson/curated-search-next /opt/curated-search
cd /opt/curated-search
(git clone asks for the token as the password; a
deploy key
works too.) The compose file only needs the repository for itself,
and .env: the application comes from the image
that CI publishes for every green master.
3. Create .env next to compose.prod.yaml:
DOMAIN=search.example.com
SECRET_KEY=... # python3 -c "import secrets; print(secrets.token_urlsafe(50))"
POSTGRES_PASSWORD=... # any long random string
APP_DB_PASSWORD=... # another one, for the app's database role
REDIS_PASSWORD=... # openssl rand -hex 32 (it goes into a URL)
ADMIN_URL=some-secret-admin-path
# Email: see "Email" below
EMAIL_HOST=smtp.protonmail.ch
EMAIL_HOST_USER=search@example.org
EMAIL_HOST_PASSWORD=... # Proton SMTP token, not your account password
DEFAULT_FROM_EMAIL=search@example.org
ADMINS=you@example.org # receives error reports
# Shown on the privacy page (/privacy/) and in the footer
SITE_OWNER=Your Name
CONTACT_EMAIL=you@example.org
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS and SITE_URL (used for links in
digest emails) default to DOMAIN. When feeds are refreshed and digests
sent is set in the admin (see Scheduled jobs).
- Create the network that Caddy and
webshare, and attach Caddy to it:
docker network create --internal -o com.docker.network.bridge.name=br-cs-proxy curated-search-proxy
docker network connect curated-search-proxy <caddy container>
--internal gives the network no route out, so the app's own traffic
to the internet leaves through curated-search_default. List the network
under Caddy's networks: (as external: true) in Caddy's compose file
too, or it's lost the next time Caddy's container is recreated.
- Start the stack and create an admin account:
docker compose -f compose.prod.yaml up -d
docker compose -f compose.prod.yaml exec web python manage.py createsuperuser
docker compose -f compose.prod.yaml ps web # "healthy" after a few seconds
Staff accounts must use two-factor authentication. The first time you
open the admin, the site asks you to set up an authenticator app (any
TOTP app, e.g. Aegis, 2FAS or 1Password) or a passkey, and gives you
recovery codes; keep those somewhere safe. If you're ever locked out,
set STAFF_REQUIRE_2FA=false in .env, restart web, and fix it in the
admin; then remove the setting again.
Every account must confirm its email address before it can sign in, the
admin account included: the first login emails you a link. (Without
EMAIL_HOST the email isn't sent but logged; find the link with
docker compose -f compose.prod.yaml logs web.)
- Add a site block to Caddy's Caddyfile;
deploy/Caddyfile.proxyin the repository has it ready to copy:
search.example.com {
encode zstd gzip
# Don't announce the server software (gunicorn's or Caddy's).
header -Server
reverse_proxy curated-search-web:8000 {
health_uri /healthz
health_interval 30s
}
}
curated-search-web is the name web has on the shared network. Reload
Caddy (docker exec <caddy container> caddy reload --config /etc/caddy/Caddyfile),
then check curl -s https://search.example.com/healthz ({"status": "ok", ...}).
Caddy obtains the certificate and sets X-Forwarded-Proto itself.
- Add feeds, either in the admin (
https://DOMAIN/ADMIN_URL/, which also imports OPML) or from the command line:
The site's domain (used in email links) is set from DOMAIN automatically
on every start.
Site settings, signup and invitations¶
Site settings in the admin hold the site's name (header, browser tab,
emails, authenticator apps), the home page's heading, and the signup switch.
Changes apply at once, also on cached pages. SITE_NAME and SIGNUP_OPEN
in .env are only read once, when the settings are created by the first
migrate; SITE_NAME also stays the prefix of error emails to ADMINS.
Signup is closed by default. While it's closed, the signup page shows the note from the site settings, the header has no "Sign up" link, and only invited people can create an account. Tick Signup open to let anyone in. Every new account must confirm its email address before signing in.
Invitations (admin › Invitations › Add) take an email address and send
it a sign-up link right away; the admin says whether the email went out.
The link works for INVITATION_EXPIRY_DAYS (7) days, once, and only for
that address, which counts as confirmed. Resend on an invitation makes a
new link (the old one stops working) and emails it again. An address that
already has an account or a pending invitation is refused. Unused
invitations are deleted 30 days after they expire (the prune-invitations
job), as the privacy page says.
On a server without a reverse proxy¶
Add the bundled Caddy, which listens on 80/443 and gets certificates for
DOMAIN automatically. compose.prod.yaml still expects the shared network,
so create it first (the bundled Caddy reaches web on the stack's own network):
docker network create --internal curated-search-proxy
docker compose -f compose.prod.yaml -f compose.caddy.yaml up -d
Security headers¶
The app sets its own security headers, so leave them out of the Caddy site
block (a second copy of a header can conflict): HSTS, X-Frame-Options,
Referrer-Policy, nosniff, a Content-Security-Policy and a
Permissions-Policy. The one header to set in Caddy is a removal:
header -Server drops the Server: gunicorn banner, which the app can't.
The Content-Security-Policy allows scripts, styles, images and requests
only from the site itself (plus data: images: the favicon and the
authenticator QR code). If it ever blocks something it shouldn't, set
CSP_REPORT_ONLY=true in .env and restart web: browsers then only
report violations in their developer console. Remove it again once fixed.
Rate limits¶
Search (120 a minute), the search Atom feed (30 a minute) and feed
suggestions (10 an hour) are limited per visitor: per account when signed
in, per IP otherwise. Past a limit the visitor gets a "slow down" page (HTTP
429). Login, signup and password resets have allauth's own limits. Adjust
the limits with RATE_LIMIT_SEARCH, RATE_LIMIT_SEARCH_FEED and
RATE_LIMIT_SUGGEST in .env (e.g. 60/m, 500/h; empty turns one off).
The client IP is taken from the last X-Forwarded-For entry, which is the
one your Caddy adds. This is right for one proxy (your Caddy container or
the bundled one). With another proxy in front, such as Cloudflare, set
TRUSTED_PROXY_COUNT=2. Otherwise every visitor seems to share one
address and one allowance.
Email¶
The app sends sign-up confirmations, password resets, saved-search digests
and, to ADMINS, error reports. Any SMTP server works:
| Setting | Default | |
|---|---|---|
EMAIL_HOST |
(none: emails are only logged) | |
EMAIL_PORT |
587 |
587 uses STARTTLS, 465 implicit TLS |
EMAIL_HOST_USER |
||
EMAIL_HOST_PASSWORD |
||
DEFAULT_FROM_EMAIL |
noreply@example.com |
must be an address the account may use |
EMAIL_USE_TLS / EMAIL_USE_SSL |
from the port | |
EMAIL_TIMEOUT |
15 seconds |
|
ADMINS |
comma-separated; get 500-error reports | |
SERVER_EMAIL |
DEFAULT_FROM_EMAIL |
sender of error reports |
Proton Mail. Proton's SMTP submission works with custom-domain addresses on paid plans. In Proton, open Settings → IMAP/SMTP → SMTP tokens, create a token for the address the site should send from, and use:
EMAIL_HOST=smtp.protonmail.ch
EMAIL_PORT=587
EMAIL_HOST_USER=search@example.org # the address the token belongs to
EMAIL_HOST_PASSWORD=<the SMTP token>
DEFAULT_FROM_EMAIL=search@example.org # Proton only sends from that address
(Check Proton's current SMTP documentation if anything has changed.)
Test it once the stack is up:
docker compose -f compose.prod.yaml exec web python manage.py sendtestemail you@example.org
docker compose -f compose.prod.yaml exec web python manage.py check --deploy
check --deploy reports an error (mail.E001) if no SMTP server is
configured and a warning (core.W002) for a placeholder sender address. If
the mail server is unreachable, pages that send email still work: the
failure is logged, and a digest that couldn't be sent is retried the next day.
Updating¶
Every merge to master that passes CI publishes a new image
(ghcr.io/joeriksson/curated-search-next:latest, plus :sha-<commit>).
To deploy it:
That pulls the repository and the image, recreates the containers whose
image or configuration changed (web applies migrations on start), and
removes superseded images. It also restarts backup when
deploy/backup.sh changed, since the running container keeps the old
script. BUILD=1 deploy/update.sh builds the image on
the server instead of pulling it.
To pin or roll back to a specific build, set IMAGE in .env, e.g.
IMAGE=ghcr.io/joeriksson/curated-search-next:sha-1a2b3c4, and run
docker compose -f compose.prod.yaml up -d. Migrations only go forward, so
roll back across a migration only with a database restore.
Scheduled jobs¶
Celery beat reads its schedule from the database (django-celery-beat), so the jobs are listed, and changed, in the admin under Periodic tasks, with when each last ran. Changes take effect within a few seconds, without a restart. The first migration creates:
| Job | Default |
|---|---|
refresh-feeds |
every 30 minutes |
send-digests |
daily at 06:00 UTC |
clear-expired-sessions |
daily at 04:15 UTC |
prune-search-stats |
daily at 04:30 UTC |
(celery.backend_cleanup is Celery's own; leave it.)
- Digest time. Edit the
send-digestscrontab. It has its own time zone, so e.g. 07:00Europe/Stockholmfollows daylight saving time. A daily digest is due once per calendar day in that time zone, so moving the time earlier never skips a day; moving it later delivers that day's digest late once. - Refresh interval. Edit the
refresh-feedsinterval. Monitoring follows it: refreshes count as late after 3 × the interval, unlessMONITOR_REFRESH_LATE_MINUTESis set. Failing feeds back off fromFEED_BACKOFF_BASE_MINUTES(30) regardless of the interval.
The beat container waits until the web container has applied migrations, so a fresh install doesn't start with an empty schedule.
Upgrading from env-var schedules. DIGEST_HOUR_UTC and
FEED_REFRESH_MINUTES are read once, by the migration that creates the
jobs, and ignored after that: remove them from .env and change the times
in the admin instead. FEED_REFRESH_MINUTES no longer sets the backoff
base either; set FEED_BACKOFF_BASE_MINUTES if you relied on that.
Backups¶
The backup service dumps the database every day at BACKUP_HOUR_UTC
(default 3) into ./backups next to the compose file, keeping the newest
BACKUP_KEEP (default 14). It also backs up right away when it starts and
no dump from the last 24 hours exists, so a new deployment has one within
seconds. The database is the only state; the dumps are all you need.
- Files are
backups/curated-search-YYYYMMDD-HHMMSS.dump(pg_dump custom format), readable only by root because they contain user data. docker compose -f compose.prod.yaml psshowsbackupas unhealthy if the newest dump is more than 26 hours old.- Take one now:
docker compose -f compose.prod.yaml exec backup /backup.sh now - Copy them off the server regularly (another machine, object storage);
a backup on the same disk doesn't survive losing the server. For example
from another machine:
rsync -a vps:/opt/curated-search/backups/ ./curated-search-backups/
Restore on the running server (e.g. after a bad import):
cd /opt/curated-search
docker compose -f compose.prod.yaml stop web worker beat
docker compose -f compose.prod.yaml exec -T db pg_restore -U curated_search \
-d curated_search --clean --if-exists --no-owner --no-acl < backups/<file>.dump
docker compose -f compose.prod.yaml start web worker beat
Restore on a new server: follow "First deploy" up to .env, then start
only the database, restore, and start the rest:
docker compose -f compose.prod.yaml up -d db
docker compose -f compose.prod.yaml exec -T db pg_restore -U curated_search \
-d curated_search --no-owner --no-acl < curated-search-<date>.dump
docker compose -f compose.prod.yaml up -d
Restore as curated_search, as above, so the restored tables belong to the
app's role (see Database roles).
Both procedures were tested against the production compose file: the restored database has all entries, search vectors and spelling terms, and the app starts with no migrations to apply.
Database roles¶
The app connects as curated_search, a role that owns the curated_search
database but isn't a superuser: it can run migrations, but it can't read
files or run programs on the database server, or touch other databases and
roles. The postgres superuser (POSTGRES_PASSWORD) is used only by the
backup service and for admin work.
deploy/initdb.sh creates the role and the database, with the password
from APP_DB_PASSWORD. The db container runs it when its data volume is
new. check --deploy --database default warns (core.W004) if the app
connects as a superuser.
Upgrading an installation that predates this. The app's tables used to
be in the postgres database, owned by postgres. Move them into the new
database (a few minutes of downtime):
cd /opt/curated-search
git pull --ff-only
# Add APP_DB_PASSWORD=<a long random string> to .env, then:
C() { docker compose -f compose.prod.yaml "$@"; }
export APP_DB_PASSWORD="$(grep '^APP_DB_PASSWORD=' .env | cut -d= -f2-)"
C exec -T -e APP_DB_PASSWORD db sh < deploy/initdb.sh
C stop web worker beat
C exec -T db sh -c 'pg_dump -U postgres --format=custom postgres > /tmp/move.dump &&
pg_restore -U curated_search -d curated_search --no-owner --no-acl --exit-on-error /tmp/move.dump &&
rm /tmp/move.dump'
deploy/update.sh
The old tables stay in postgres until you drop them, so going back is a
matter of checking out the previous commit and running up -d. Once the
site has run well for a while, drop them:
C exec -T db psql -U postgres -d postgres <<'SQL'
DROP SCHEMA public CASCADE;
CREATE SCHEMA public AUTHORIZATION pg_database_owner;
GRANT USAGE ON SCHEMA public TO PUBLIC;
SQL
Upgrading to the Redis password¶
On an installation that predates the Redis password, add
REDIS_PASSWORD to .env (openssl rand -hex 32) before running
deploy/update.sh. The update recreates every container, db and redis
included, so the site is down for a few seconds. Cached pages and
rate-limit counters start empty. Wait until no Celery task is running: a
task still queued under the old Redis is lost, and the next scheduled run
does its work.
Search engines¶
Other search engines should find the site through its own pages, not its search results or the publishers' articles:
/robots.txtkeeps crawlers off the search results (endless URLs, each a full-text query),/accounts/and/library/, and points to/sitemap.xml. It doesn't list the admin: its path is a secret./sitemap.xmllists the home page, the feed list, Latest, the privacy page and every topic with active feeds. Submit it in Google Search Console or Bing Webmaster Tools if you like.- Every other page sends
X-Robots-Tag: noindex, for crawlers that ignore robots.txt. Article ("Similar") pages sendnoindex, follow: they show the publishers' text, so the originals should rank, and their links should still be followed. - Searches by clients whose User-Agent says they are a bot aren't counted in the search insights.
Both use SITE_URL (by default https://DOMAIN) for their links.
Caching¶
Pages for visitors who aren't signed in (home, Latest, feeds, topics) are
cached in Redis for up to 5 minutes, and refresh as soon as new articles
arrive. Atom feeds send ETag/Last-Modified, so feed readers that poll
get an empty "304 Not Modified" until something changes. Nothing needs
configuring.
If Redis goes down, the site keeps working without the cache: pages are
rendered fresh, and rate limits (the site's and the login form's) are off
until Redis is back. /healthz reports it, and the logs say so once a
minute.
Health and logs¶
GET /healthzreturns 200 while the app can reach the database (503 otherwise). It answers plain HTTP with any Host header, so it works for container and proxy health checks. Its status is"degraded"while there are problems with the background work. The problems themselves are listed only for requests from the server itself; through the proxy the body is just the status. To see them:
docker compose -f compose.prod.yaml exec web python -c \
"import urllib.request; print(urllib.request.urlopen('http://localhost:8000/healthz').read().decode())"
ADMINS when feed refreshes stop (the Celery
worker or beat is down), when the newest backup is over 26 hours old, or
when a feed has failed 5 times in a row. It sends one email per problem,
a reminder once a day while it lasts, and an all-clear afterwards. The checks
run inside the web container, triggered by Docker's own health checks, so
they work even when Celery doesn't. Tune them with
MONITOR_REFRESH_LATE_MINUTES (default 3 × the refresh-feeds interval) and
MONITOR_FEED_FAILURES.
- External uptime monitor (optional). If the whole server goes down, the
app can't email you. For that, point a free monitor (UptimeRobot, Uptime
Kuma, healthchecks.io…) at https://your-domain/healthz?strict=1, which
returns 503 while any problem above persists.
- docker compose -f compose.prod.yaml logs -f web worker for logs. Each
container keeps at most 5 × 10 MB of logs; older lines are dropped. The
web log includes search terms (but not visitors' IPs), so this also limits
how long they're kept, as the privacy page says.
- Security events go to the web log on the security logger, one line
each: login, login_failed, login_code_rejected, mfa_failed,
signup, password, email and authenticator changes, account_deleted,
and rate_limited. They name accounts by id. Only failures and
rate-limit hits include the client's IP. To see them:
docker compose -f compose.prod.yaml logs web | grep -E 'login_failed|rate_limited|mfa_failed'.
The access log masks the keys in invitation, unsubscribe, email
confirmation and password reset links (core/gunicorn.py). Caddy's own
access log, if you turned it on, still has them.
- Failing or stale feeds are listed at /feeds/health/ (staff only).