yk-portal deployment guide
How to install yk-portal on Ubuntu 26.04 LTS, put it behind a reverse proxy, run it under systemd, check it, upgrade it and go back (spec §49; addendum §20). The README has the commands, the audit log and backups in detail.
| What | Where |
|---|---|
| The code, one directory per installation | /opt/yk-portal/releases/VERSION-TIME, root's |
| The release in use | /opt/yk-portal/current → releases/VERSION-TIME |
The yk-portal command |
/usr/local/bin/yk-portal → /opt/yk-portal/current/bin/yk-portal |
| Configuration and master key | /etc/yk-portal/ |
| The SQLite database | /var/lib/yk-portal/ |
| The service | yk-portal.service, running yk-portal serve (gunicorn) as yk-portal |
| Logs | the system journal: journalctl -u yk-portal, journalctl -t yk-portal-audit |
The application listens on 127.0.0.1:8000 for the reverse proxy, which
terminates TLS (spec §3, §32). Files in the repository: deploy/install,
deploy/yk-portal.service, deploy/nginx/, config.example.toml.
Prerequisites
Ubuntu 26.04's Python (3.14.4 or newer), libpq5 for PostgreSQL, and
uv 0.13.1 or newer, installed for root:
sudo apt install python3 libpq5 curl
curl -LsSf https://astral.sh/uv/0.13.1/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh
uv installs the locked dependencies only; the system's Python, so Ubuntu's security updates apply to it. With PostgreSQL, psycopg's C implementation makes backups and restores about 1.5 times as fast (addendum §20.6); it is built at install time and needs:
sudo apt install build-essential python3-dev libpq-dev
Install
From the release's source tree (a checkout or an unpacked archive), as root:
sudo deploy/install # SQLite
sudo deploy/install --postgresql-c # PostgreSQL, with psycopg's C implementation
deploy/install creates the yk-portal service account (no login shell),
/etc/yk-portal (root:yk-portal, 0750), /var/lib/yk-portal and
/var/backups/yk-portal (yk-portal, 0700), installs the release into a new
directory, /opt/yk-portal/releases/VERSION-TIME (such as
0.1.0-20261019T053000Z), points current and /usr/local/bin/yk-portal at
it, and installs yk-portal.service. A first install also copies
config.example.toml to /etc/yk-portal/config.toml (root:yk-portal,
0640). It never changes the configuration, the master key or the database,
and never starts or stops the service: a running service keeps the release it
started with, until it is restarted. Run it again at any time to repair an
installation: it installs a fresh copy and switches to it.
Configure
sudoedit /etc/yk-portal/config.toml # public_base_url, and [database] for PostgreSQL
Only settings needed before the database is reachable are in the file
(addendum §3.5); timeouts, the portal name and the sign-in limits are on the
admin settings page. public_base_url is the address users browse to, such as
https://yk-portal.example.com, or a path of a site
(https://www.example.com/yk, see Under a path).
trusted_proxy_addresses lists the reverse proxy's addresses; the default,
127.0.0.1 and ::1, is right for a proxy on the same server.
The master key encrypts the stored YubiKey secrets and the backups (spec §22.4). Create it once and keep a copy somewhere safe, apart from the backups:
sudo yk-portal master-key-create # /etc/yk-portal/master.key, mode 0600
sudo chown yk-portal:yk-portal /etc/yk-portal/master.key
SQLite
Nothing more to do: the database is /var/lib/yk-portal/yk-portal.sqlite3.
PostgreSQL
Give yk-portal a role of its own that owns its database and has no other
privileges, and a UTF-8 database with the C.UTF-8 locale (addendum §18.2):
sudo -u postgres psql <<'SQL'
CREATE ROLE yk_portal LOGIN;
CREATE DATABASE yk_portal OWNER yk_portal
ENCODING 'UTF8' LOCALE 'C.UTF-8' TEMPLATE template0;
REVOKE ALL ON DATABASE yk_portal FROM PUBLIC;
SQL
sudo -u postgres psql -c '\password yk_portal' # prompts, so it stays out of shell history
Put the password in a passfile only the service account can read, and name it in the connection string, with TLS across the network:
sudo install -m 0600 -o yk-portal -g yk-portal /dev/null /etc/yk-portal/pgpass
sudoedit /etc/yk-portal/pgpass # one line: db.example.com:5432:yk_portal:yk_portal:PASSWORD
[database]
backend = "postgresql"
postgres_dsn = "host=db.example.com dbname=yk_portal user=yk_portal sslmode=verify-full sslrootcert=/etc/yk-portal/db-ca.crt passfile=/etc/yk-portal/pgpass"
On the same server, peer authentication over the Unix socket needs no
password: name the role after the service account (CREATE ROLE "yk-portal" LOGIN, owning the database) and use postgres_dsn = "dbname=yk_portal user=yk-portal".
The database and the first admin
sudo -u yk-portal yk-portal migrate
sudo -u yk-portal yk-portal database-status
sudo -u yk-portal yk-portal bootstrap-admin root # prints a temporary password
Give the temporary password to the admin privately; they sign in with it, then add their YubiKey and choose its PIN.
Start the service
sudo systemctl enable --now yk-portal
systemctl status yk-portal
curl -s http://127.0.0.1:8000/health/ready # {"status": "ready"}
yk-portal serve runs gunicorn with the configured number of workers
([application] workers, 4 by default) and tells systemd once it listens. It
loads the application before starting the workers, so a missing master key
or a wrong setting stops it at once, with the reason in
journalctl -u yk-portal; systemd tries again 5 times in 2 minutes, then
leaves it failed. Once fixed: sudo systemctl reset-failed yk-portal, then
start it.
The unit runs the service as yk-portal in a sandbox: no privileges, a
read-only system, no home directories, its own /tmp and /dev, only the
network families it needs, and writes only to /var/lib/yk-portal.
systemd-analyze security yk-portal rates it 1.1, "OK". The commands
(sudo -u yk-portal yk-portal ...) run outside it, as the same account.
Reverse proxy
The proxy terminates TLS with the enterprise's certificate, redirects HTTP to
HTTPS, sends the client's address in X-Forwarded-For and the scheme in
X-Forwarded-Proto, limits request sizes and the rate of sign-ins, and
proxies to listen_address:listen_port only (spec §32). yk-portal sets the
security headers and the caching of every response itself: the proxy
shouldn't add them as well.
yk-portal believes X-Forwarded-For and X-Forwarded-Proto only from the
addresses in trusted_proxy_addresses. A request from anywhere else can't
say it came over HTTPS: it is redirected to public_base_url.
nginx, on a host name of its own
deploy/nginx/yk-portal.conf is a whole
configuration for https://yk-portal.example.com:
sudo install -m 0644 deploy/nginx/yk-portal.conf /etc/nginx/conf.d/yk-portal.conf
sudoedit /etc/nginx/conf.d/yk-portal.conf # server_name, ssl_certificate, ssl_certificate_key
sudo nginx -t && sudo systemctl reload nginx
It limits sign-in attempts to 10 a minute per client address (bursts of 10),
before yk-portal's own limit on failed sign-ins (addendum §20.4), refuses
bodies over 256 KiB, and answers 404 for /health/, which is for local
monitoring.
Under a path
yk-portal can be published under a path of an existing site, such as
https://www.example.com/yk/ (addendum §20.2): set public_base_url = "https://www.example.com/yk". The proxy strips the path before passing the
request on, and yk-portal puts it back in its links, redirects and cookies.
deploy/nginx/yk-portal-path.conf has
the two parts: one for the http { } context, one to include in the site's
server { } block.
yk-portal then shares the site's origin, so script on any page of the site can read yk-portal's pages with the user's session. Publish it under a path only on a site whose other pages are trusted as much; a host name of its own keeps it apart. Under a path, yk-portal sends no HSTS header, since its policy would be the whole site's: the site's own applies.
Other proxies
Caddy and Apache work the same way: proxy to 127.0.0.1:8000, set
X-Forwarded-For to the client's address (replacing what the client sent)
and X-Forwarded-Proto to https, keep the Host header, and for a path,
strip it. Caddy's reverse_proxy sets the forwarded headers itself.
Check it
curl -s http://127.0.0.1:8000/health/live # the process answers
curl -s http://127.0.0.1:8000/health/ready # it can serve: database, migrations, master key
sudo -u yk-portal yk-portal database-status
sudo -u yk-portal yk-portal check --deploy # Django's own checks of the settings
curl -sI https://yk-portal.example.com/login/ # Content-Security-Policy, Strict-Transport-Security, ...
/health/ready answers 503 with the failed checks' names (database,
migrations, master key), never a value from the configuration;
journalctl -u yk-portal has the reasons. The health checks answer any host
name, so monitoring can ask 127.0.0.1:8000 directly.
Backups
backup-create runs while yk-portal runs; see the README, "Backup and
restore", for a nightly cron job, verifying and restoring. Keep a copy of
/etc/yk-portal/master.key apart from the backups: a backup can't be read
without it.
Upgrade
- Back up, with the version still installed (a backup is made from a
database with all of its version's migrations):
sudo -u yk-portal yk-portal backup-create /var/backups/yk-portal - Install the new release from its source tree. The service keeps running
the release it started with:
sudo deploy/install # with --postgresql-c if installed with it - Stop, migrate, start:
sudo systemctl stop yk-portal sudo -u yk-portal yk-portal migrate sudo systemctl start yk-portal curl -s http://127.0.0.1:8000/health/ready
Do steps 2 and 3 together. In between, the yk-portal command is already the
new release's while the database isn't migrated yet: a backup from cron is
refused ("isn't fully migrated"), and a restart of the service would start
the new release, which /health/ready reports as migrations until step 3.
ls /opt/yk-portal/releases lists the releases installed; remove old ones
with sudo rm -r /opt/yk-portal/releases/VERSION-TIME once they're no longer
needed for going back, never the one current points at.
Go back to the previous release
A database migrated by the new release can't be used by the old one, and a backup restores only with the release that made it (addendum §19.5). So go back with the backup made before the upgrade:
sudo systemctl stop yk-portal
ls /opt/yk-portal/releases # the previous one, VERSION-TIME
sudo ln -sfn releases/VERSION-TIME /opt/yk-portal/current
Then give the old release an empty database and restore. SQLite:
sudo mv /var/lib/yk-portal/yk-portal.sqlite3 /var/lib/yk-portal/yk-portal.sqlite3.new-release
sudo rm -f /var/lib/yk-portal/yk-portal.sqlite3-wal /var/lib/yk-portal/yk-portal.sqlite3-shm
PostgreSQL: DROP DATABASE yk_portal and create it again as in
PostgreSQL. Then, for either:
sudo -u yk-portal yk-portal migrate
sudo -u yk-portal yk-portal backup-restore /var/backups/yk-portal/yk-portal-YYYYMMDDTHHMMSSZ.backup
sudo systemctl start yk-portal
Changes made since the backup are lost, and the counters of YubiKeys used since are behind: move them forward from radiator-yubikey (README, "Backup and restore").
Files and permissions
| Path | Owner | Mode | Holds |
|---|---|---|---|
/etc/yk-portal/ |
root:yk-portal |
0750 | |
/etc/yk-portal/config.toml |
root:yk-portal |
0640 | the configuration: no secrets |
/etc/yk-portal/master.key |
yk-portal:yk-portal |
0600 | the master key; yk-portal refuses a key others can read |
/etc/yk-portal/pgpass |
yk-portal:yk-portal |
0600 | the PostgreSQL password |
/var/lib/yk-portal/ |
yk-portal:yk-portal |
0700 | the SQLite database (0600) |
/opt/yk-portal/ |
root:root |
0755 | the code: the service can't change it |
/var/backups/yk-portal/ |
yk-portal:yk-portal |
0700 | backups (0600), by backup-create |
Every yk-portal command and the service create their files for the service
account alone (umask 077), whatever the umask of the shell or sudo. There is
no /var/log/yk-portal: the logs go to the journal.
Logs
| Log | Holds | Read it with |
|---|---|---|
| The service's | startup, readiness problems, request errors, warnings | journalctl -u yk-portal |
| The audit log's copy | every audit event, from the service and the commands | journalctl -t yk-portal-audit |
Requests are logged by the reverse proxy, which knows the clients' addresses;
gunicorn's own access log is off. Neither log ever holds a secret (spec §40).
[logging] level sets the service's level; the audit copy is written at any
level.
Troubleshooting
| Symptom | Cause |
|---|---|
The service stops at once, then is failed; journalctl -u yk-portal names the master key |
/etc/yk-portal/master.key missing, unreadable by yk-portal, or readable by others; once fixed, sudo systemctl reset-failed yk-portal and start it |
/health/ready says migrations |
run sudo -u yk-portal yk-portal migrate |
/health/ready says master key after the key file changed |
the service still uses the key it read: restart it |
| 400 Bad Request for every page | the browser's host name isn't public_base_url's |
| The browser loops between redirects | the proxy doesn't send X-Forwarded-Proto: https, or its address isn't in trusted_proxy_addresses |
| "Form not accepted" on every form | the page wasn't served over HTTPS, or public_base_url differs from the address in the browser |
| The journal says a sign-in came "from a trusted proxy, without the client's address" | the proxy doesn't send X-Forwarded-For: failed sign-ins can't be limited per address |
| Everyone must wait after a few failed sign-ins anywhere | the proxy's address isn't in trusted_proxy_addresses, so every client looks like the proxy |
| An address must wait | 10 failed sign-ins from it within 15 minutes (the settings page); it waits until the oldest is 15 minutes old |
| 502 from the proxy | the service isn't running, or listens elsewhere than the proxy's upstream |