From e7c872377af3c06cb60f18d54ee7f21225247f2a Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Tue, 11 Aug 2026 16:33:20 +0200 Subject: [PATCH 1/7] docs: document session authentication for docker Adds a session authentication section to the docker page covering ROTKI_SESSION_KEY, single-tab behaviour and the reverse proxy caveats, and points the mobile guide at it. The escape hatch is now ROTKI_ACCEPT_UNAUTHENTICATED_API. The older ROTKI_ACCEPT_DOCKER_RISK is no longer read, so both pages say so: it acknowledged a warning that never mentioned authentication, and carrying it over would leave those operators unaware the option exists. --- requirement-and-installation/docker.md | 52 ++++++++++++++++++++++++++ usage-guides/advanced/mobile.md | 8 +++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/requirement-and-installation/docker.md b/requirement-and-installation/docker.md index a94af10..3c74473 100644 --- a/requirement-and-installation/docker.md +++ b/requirement-and-installation/docker.md @@ -82,6 +82,58 @@ docker run -d --name rotki \ Supported variables: `LOGLEVEL`, `LOGFROMOTHERMODULES`, `MAX_SIZE_IN_MB_ALL_LOGS`, `MAX_LOGFILES_NUM`, `SQLITE_INSTRUCTIONS`. Changing these requires recreating the container. +## Session authentication + +By default the Docker image serves an unauthenticated API. Anything that can reach the container's port can use it and read your data without signing in, which is why rotki shows a warning on the login screen when it runs in Docker. + +Setting `ROTKI_SESSION_KEY` turns on session authentication. Generate a random key and pass it to the container: + +```sh +docker run -d --name rotki \ + -p 8084:80 \ + -v $HOME/.rotki/data:/data \ + -v $HOME/.rotki/logs:/logs \ + -e ROTKI_SESSION_KEY="$(openssl rand -hex 32)" \ + rotki/rotki:latest +``` + +With the key set: + +- Signing in issues an `HttpOnly` session cookie. Any request that does not carry it is rejected with `401`, and the websocket connection is refused as well. +- Only one session is active at a time. Signing in from another browser or device signs the previous one out. +- The login screen no longer shows the Docker warning, because there is no longer an unauthenticated instance to warn about. + +Keep the key stable across restarts. If it changes, or is dropped, every existing session becomes invalid and you have to sign in again. With Docker Compose, put it in the `.env` file next to `docker-compose.yml`: + +```sh +ROTKI_SESSION_KEY= +``` + +and reference it from the `rotki` service: + +```yaml +services: + rotki: + environment: + - ROTKI_SESSION_KEY=${ROTKI_SESSION_KEY} +``` + +> [!NOTE] +> Session authentication protects the API, it does not replace network isolation. Keep the container off the public internet, and if you put a reverse proxy in front of it keep the TLS and basic auth from the [example below](#public-network-with-traefik-basic-auth). + +### Accepting the risk instead + +If you know the instance is unreachable by anyone else and you do not want session authentication, you can dismiss the warning permanently: + +```sh +ROTKI_ACCEPT_UNAUTHENTICATED_API=1 +``` + +This only hides the warning. It changes nothing about who can reach the API, so prefer `ROTKI_SESSION_KEY` whenever you can. + +> [!NOTE] +> This replaces `ROTKI_ACCEPT_DOCKER_RISK`, which is no longer read. That variable acknowledged an older, vaguer warning that never mentioned authentication, so it is deliberately not carried over. If you had set it, you will see the new warning once, and can then pick between `ROTKI_SESSION_KEY` and the variable above. + ## Setting the timezone Set `TZ` when starting the container: diff --git a/usage-guides/advanced/mobile.md b/usage-guides/advanced/mobile.md index 3a38fe3..c30c741 100644 --- a/usage-guides/advanced/mobile.md +++ b/usage-guides/advanced/mobile.md @@ -28,8 +28,12 @@ Alternatively, if you already have a VPN setup to your private network or on the ![rotki warning for docker](/images/rotki_docker_warning.png) -For awareness reasons, if the app is run in Docker, you will see this warning every time it is opened. You can turn it off by running the rotki Docker image with this environment variable: +For awareness reasons, if the app is run in Docker without authentication, you will see this warning every time it is opened. + +The recommended way to make it go away is to [turn on session authentication](/requirement-and-installation/docker#session-authentication), which makes rotki require a signed-in session for every request. If you would rather keep the instance unauthenticated, you can dismiss the warning by running the Docker image with this environment variable: ```sh -ROTKI_ACCEPT_DOCKER_RISK=1 +ROTKI_ACCEPT_UNAUTHENTICATED_API=1 ``` + +If you had set the older `ROTKI_ACCEPT_DOCKER_RISK`, it is no longer read: you will see the warning once and can then choose either of the two variables above. From b0bfd61d78674b4a8c279710420c9ac0c4b4f3a5 Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Tue, 11 Aug 2026 16:54:31 +0200 Subject: [PATCH 2/7] docs: restructure the docker security section The page told you to set a session key but never said what the published port actually exposes, so there was no way to judge whether that was enough. It now leads with the surface: a table of every path on the port and what guards it with and without a session key, and a second table of the calls that stay reachable before sign-in, which include account enumeration and ungated account creation. Says plainly what session authentication does not do: no TLS, so the password and cookie cross the network in cleartext; no rate limiting on sign-in; a 7 day session. An authenticating reverse proxy that terminates TLS is the recommendation, with the session key alongside it rather than instead of it, since a proxy you control is what makes reaching rotki over the internet safe. Adds the hardened run recipe, and corrects the volume ownership note: the container drops to uid 10001 and takes ownership of the mounts, it does not need them owned by root. --- requirement-and-installation/docker.md | 112 +++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 8 deletions(-) diff --git a/requirement-and-installation/docker.md b/requirement-and-installation/docker.md index 3c74473..0bfb144 100644 --- a/requirement-and-installation/docker.md +++ b/requirement-and-installation/docker.md @@ -7,7 +7,7 @@ description: Run rotki in a Docker container, with configuration, Docker Compose rotki provides official Docker images starting from v1.11.0. Images are published on [DockerHub](https://hub.docker.com/r/rotki/rotki) as `rotki/rotki`. > [!WARNING] -> rotki was not designed to run over a public network. Keep the container inside a trusted environment (for example, on your home LAN or behind a VPN) and do not expose it directly to the internet to avoid unauthorized access to your data. +> Never publish the container's port directly to the internet. rotki was not built to be the only thing between the internet and your data. Keep it on a trusted network, behind a VPN, or behind a reverse proxy you control that authenticates every request and terminates TLS. See [Security](#security) for what the port exposes and how to lock it down. > [!NOTE] > Versions up to v1.13.2 report a dev version inside the app due to an old build process issue. This is cosmetic and doesn't affect functionality. @@ -34,8 +34,14 @@ Open `http://localhost:8084` in your browser and you'll see the rotki login scre Your account data lives under `~/.rotki/data`; your logs are under `~/.rotki/logs`. Both survive container restarts and upgrades as long as you reuse the same volumes. -> [!WARNING] -> On Linux, the mounted `data` and `logs` folders must be owned by `root` — that's the user the container runs as. If you change the owner, the container will hit permission errors and rotki will return 500 responses. +> [!NOTE] +> On first start the container takes ownership of the mounted `data` and `logs` folders, setting them to uid/gid `10001`, which is what the unprivileged backends run as. On the host those files are then no longer owned by your own user, so editing or deleting them directly needs `sudo` or a throwaway container: +> +> ```sh +> docker run --rm -v $HOME/.rotki/data:/data debian:12-slim rm -rf /data/some-file +> ``` +> +> If you pass `docker run --user ` the container skips the drop and runs as that uid instead, but then the volumes must already be writable by it. ## Configuring the backend @@ -80,11 +86,54 @@ docker run -d --name rotki \ rotki/rotki:latest ``` -Supported variables: `LOGLEVEL`, `LOGFROMOTHERMODULES`, `MAX_SIZE_IN_MB_ALL_LOGS`, `MAX_LOGFILES_NUM`, `SQLITE_INSTRUCTIONS`. Changing these requires recreating the container. +| Variable | Meaning | +| ---------------------------------- | ------------------------------------------------------------------- | +| `LOGLEVEL` | backend log level | +| `LOGFROMOTHERMODULES` | include third-party library logs | +| `MAX_SIZE_IN_MB_ALL_LOGS` | total log size budget | +| `MAX_LOGFILES_NUM` | rotated log files to keep | +| `SQLITE_INSTRUCTIONS` | SQLite instructions-per-context | +| `ROTKI_HTTP_PORT` | the port rotki serves on _inside_ the container (default `80`) | +| `TZ` | container timezone, see [below](#setting-the-timezone) | +| `ROTKI_SESSION_KEY` | turns on session authentication, see [Security](#security) | +| `ROTKI_ACCEPT_UNAUTHENTICATED_API` | silences the unauthenticated-API warning, see [Security](#security) | + +Configuration is read once at boot, so changing any of these requires recreating the container. + +## Security + +Everything in this section is about one question: **who can reach the port you published, and what can they do once they get there.** Read it before you expose rotki to anything wider than your own machine. + +### What the published port exposes + +The container publishes a single port. Behind it, one supervisor serves the web interface and proxies to the backends, which listen only on loopback inside the container and are not reachable from outside it. These are the paths on that port: + +| Path | Without `ROTKI_SESSION_KEY` | With `ROTKI_SESSION_KEY` | +| --------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `/` and static assets | the web interface | the web interface | +| `/api/…` | **open** | session cookie required, except the pre-login calls below | +| `/colibri/…` | **open** | session cookie required | +| `/ws` | **open** | handshake refused without a live session | +| `/mcp/…` | `404`, the MCP service is not running | bearer token tied to the signed-in session | +| `/health` | open | open, by design | +| `/_control` | `404` | `GET` lists the available operations; performing one needs the session cookie, re-validated against the backend | + +`/health` answers `{"ok":…,"degraded":…}` and nothing else. It is deliberately unauthenticated so an external monitor can probe it, and it deliberately carries no pids, service names or error text: anything on the published port is readable by whoever can reach it. + +Even with a session key set, these calls are reachable **before** signing in, because the login screen itself needs them: -## Session authentication +| Call | What it gives away | +| --------------------------------------- | -------------------------------------------------------------------- | +| `GET /api/1/ping` | that rotki is here | +| `GET /api/1/info` | version, log level, and the data directory path inside the container | +| `GET /api/1/users` | **the names of every account on the instance** | +| `PUT /api/1/users` | **creates a new account** | +| `POST /api/1/users/` | sign in | +| `POST /api/1/users//authenticate` | sign in | -By default the Docker image serves an unauthenticated API. Anything that can reach the container's port can use it and read your data without signing in, which is why rotki shows a warning on the login screen when it runs in Docker. +Everything else is denied by default. Note the two in bold: account names are enumerable, and account creation is not gated. A new account cannot read an existing account's data, since each one is encrypted with its own password, but it is still an unauthenticated write by anyone who can reach the port. + +### Session authentication Setting `ROTKI_SESSION_KEY` turns on session authentication. Generate a random key and pass it to the container: @@ -118,8 +167,29 @@ services: - ROTKI_SESSION_KEY=${ROTKI_SESSION_KEY} ``` -> [!NOTE] -> Session authentication protects the API, it does not replace network isolation. Keep the container off the public internet, and if you put a reverse proxy in front of it keep the TLS and basic auth from the [example below](#public-network-with-traefik-basic-auth). +### What session authentication does not do + +Session authentication closes the open door. It is not a substitute for putting rotki behind something that terminates TLS and authenticates for it. + +- **There is no TLS.** The image speaks plain HTTP, and the session cookie is deliberately not marked `Secure` so it works on loopback and a LAN. Your account password on the way in, and the session cookie on every request after, both cross the network in cleartext. Anyone who can observe or redirect traffic between your browser and the container can read them and reuse the cookie. +- **There is no brute-force protection on sign-in.** Nothing rate-limits or locks out repeated attempts, so the strength of the whole thing is the strength of your account password. +- **The pre-login calls above stay open.** Account names remain enumerable and account creation remains ungated, with or without a session key. +- **A session lasts 7 days** unless you sign out or another sign-in takes it over. + +### Use an authenticating reverse proxy + +If rotki is reachable by anything other than the machine it runs on, put an authenticating reverse proxy in front of it and let that proxy terminate TLS. This is the recommended setup, and it stays the recommended setup with `ROTKI_SESSION_KEY` set. + +A proxy fixes exactly what the list above cannot: + +- it encrypts the connection, so the password and cookie are no longer in the clear; +- it rejects unauthenticated callers **before** they reach rotki, so the pre-login calls stop being exposed at all; +- it is where rate limiting, IP allowlisting and tools like fail2ban belong. + +The [Traefik + basic auth example](#public-network-with-traefik-basic-auth) below is a working starting point. Use the two together: the proxy keeps strangers off the port, and the session key means a request that does get through still has to carry a signed-in session. + +> [!WARNING] +> What must never be public is rotki's **own** port. Behind a proxy you control, that authenticates every request and terminates TLS, reaching rotki over the internet is fine, and that is what the Traefik example is for. The danger is publishing the container's port directly, or putting a proxy in front that forwards without authenticating: rotki was not built to be the thing standing between the internet and your data. If you would rather not run a proxy at all, a VPN into the network the container sits on gets you the same result. ### Accepting the risk instead @@ -134,6 +204,32 @@ This only hides the warning. It changes nothing about who can reach the API, so > [!NOTE] > This replaces `ROTKI_ACCEPT_DOCKER_RISK`, which is no longer read. That variable acknowledged an older, vaguer warning that never mentioned authentication, so it is deliberately not carried over. If you had set it, you will see the new warning once, and can then pick between `ROTKI_SESSION_KEY` and the variable above. +### Hardened run + +These cannot be baked into the image, they are flags you pass at run time: + +```sh +docker run -d --name rotki \ + -p 8084:80 \ + --cap-drop=ALL \ + --security-opt=no-new-privileges \ + --read-only --tmpfs /tmp --tmpfs /run \ + -v $HOME/.rotki/data:/data \ + -v $HOME/.rotki/logs:/logs \ + -e ROTKI_SESSION_KEY="$(openssl rand -hex 32)" \ + rotki/rotki:latest +``` + +`--read-only` works because there is no web-server cache or run directory left in the image. Two writable mounts are still needed: `/tmp` for scratch files and `/run` for the supervisor's control socket. + +Some of this the image already does for you, whether or not you pass the flags. The container starts as root only to bind its port and to take ownership of volumes from an older release, then drops itself and both backends to uid/gid `10001` and never regains privilege. It sets `no_new_privs` before starting anything, and it ships no setuid or setgid binaries at all, so there is nothing to regain privilege through. + +The image is built on a distroless base, so there is no shell, no package manager and no coreutils inside it. `docker exec sh` will not work. To inspect a running container, exec the supervisor binary directly: + +```sh +docker exec rotki /opt/rotki/starling ctl status +``` + ## Setting the timezone Set `TZ` when starting the container: From 8de8586d9249625d00844d6450dae2da12b9b152 Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Tue, 11 Aug 2026 16:56:45 +0200 Subject: [PATCH 3/7] docs: document docker image provenance and SBOM Released images now carry a keyless provenance attestation and an SBOM. Documents verifying either with the GitHub CLI or cosign, inspecting the SBOM, and states the version floor plus the fact that the SBOM is a partial inventory rather than the full dependency list. --- requirement-and-installation/verify.md | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/requirement-and-installation/verify.md b/requirement-and-installation/verify.md index fdc0438..0f0c31d 100644 --- a/requirement-and-installation/verify.md +++ b/requirement-and-installation/verify.md @@ -94,3 +94,52 @@ rotki/rotki https://slsa.dev/provenance/v1 .github/workflows/rotki_release.yam > [!WARNING] > GitHub artifact attestations will fail for Windows binaries (`rotki-win32_x64-*.exe`) published after August 2025. These binaries are re-signed locally using a hardware key (Yubikey) with an OV Certificate, which causes the binary hash to change after attestation. For Windows binaries after that date, verify that the binary is signed by **Rotki Solutions GmbH** instead. + +## Docker Images + +Starting with **v1.44.0**, every published `rotki/rotki` image carries two pieces of supply chain metadata: a **provenance attestation** linking the image back to the commit and workflow that built it, and an **SBOM** listing what went into it. + +> [!WARNING] +> Images published before v1.44.0 have neither, and verification against them fails with "no attestation found". That means the image predates the feature, not that it has been tampered with. The `nightly` and `edge` tags carry an SBOM but are deliberately not attested. + +### Verify provenance with the GitHub CLI + +```sh +gh attestation verify oci://docker.io/rotki/rotki:v1.44.0 --repo rotki/rotki +``` + +Expected output: + +``` +Loaded digest sha256:... for oci://docker.io/rotki/rotki:v1.44.0 +Loaded 1 attestation from GitHub API +✓ Verification succeeded! + +sha256:... was attested by: +REPO PREDICATE_TYPE WORKFLOW +rotki/rotki https://slsa.dev/provenance/v1 .github/workflows/rotki_release.yaml@refs/tags/v1.44.0 +``` + +### Verify provenance with cosign + +The attestation is also stored next to the image in the registry, so you can verify it without the GitHub CLI: + +```sh +cosign verify-attestation --type slsaprovenance1 \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/rotki/rotki/' \ + docker.io/rotki/rotki:v1.44.0 +``` + +Both commands check the same signature. You do not need to run both. + +The signing is keyless: the attestation is signed with a short-lived certificate minted from the build job's identity, so there is no long-lived public key to distribute or rotate. + +### Inspect the SBOM + +```sh +docker buildx imagetools inspect docker.io/rotki/rotki:v1.44.0 --format '{{json .SBOM}}' +``` + +> [!NOTE] +> The SBOM is a partial inventory, not a complete dependency list. It records roughly 41 packages per platform, mostly Python packages plus the base image's own. rotki's full dependency set is far larger, and none of the Rust crates appear at all, because those are linked statically and the Python bundler only preserves metadata for some packages. For the authoritative dependency list, read `uv.lock` and `Cargo.lock` at the matching tag. From a9a71a0d60dea88837aa9fa6166c103f8b7efc6f Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Tue, 11 Aug 2026 18:25:56 +0200 Subject: [PATCH 4/7] docs: recommend HSTS alongside the reverse proxy The session cookie is not marked Secure, so a hostname that is also reachable over plain HTTP will still receive it. HSTS stops the browser making that request at all, which is the part a TLS proxy alone does not cover. --- requirement-and-installation/docker.md | 1 + 1 file changed, 1 insertion(+) diff --git a/requirement-and-installation/docker.md b/requirement-and-installation/docker.md index 0bfb144..0c33b3c 100644 --- a/requirement-and-installation/docker.md +++ b/requirement-and-installation/docker.md @@ -183,6 +183,7 @@ If rotki is reachable by anything other than the machine it runs on, put an auth A proxy fixes exactly what the list above cannot: - it encrypts the connection, so the password and cookie are no longer in the clear; +- it can send `Strict-Transport-Security` (HSTS), which is worth turning on. The session cookie is not marked `Secure`, so if that same hostname is ever reachable over plain HTTP the browser will attach the cookie to those requests too. HSTS stops the browser making a plaintext request to the host at all, which closes that gap. Pair it with an unconditional HTTP to HTTPS redirect, and do not serve rotki on plain HTTP alongside; - it rejects unauthenticated callers **before** they reach rotki, so the pre-login calls stop being exposed at all; - it is where rate limiting, IP allowlisting and tools like fail2ban belong. From 22d441dc18d8c49652b78292136c2bd128236bd5 Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Wed, 12 Aug 2026 15:24:13 +0200 Subject: [PATCH 5/7] docs: document the Secure session cookie flag Three passages said the session cookie is never marked Secure. That stops being true with ROTKI_SESSION_COOKIE_SECURE, so the env table, the "no TLS" caveat and the HSTS advice all needed updating rather than just an appended section. The new section says why it is off by default (the image speaks plain http, so the flag would break sign-in on loopback or a LAN), what the three values mean, and the two things that surprise people about forwarded mode: a public terminator needs naming with --trusted-proxy or the flag is silently never set, and the default trust set covers the whole private range and can only be extended. HSTS advice reframed rather than dropped. Secure and HSTS close different halves of the same gap: one stops the cookie being sent over plaintext, the other stops the plaintext request happening at all. Also corrects the session lifetime while checking the page against the code: it was described as a flat 7 days, but the idle window is 1 day and 7 days is only the absolute ceiling a rolling session can never be extended past. --- requirement-and-installation/docker.md | 61 ++++++++++++++++++++------ 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/requirement-and-installation/docker.md b/requirement-and-installation/docker.md index 0c33b3c..e4da5a8 100644 --- a/requirement-and-installation/docker.md +++ b/requirement-and-installation/docker.md @@ -86,17 +86,18 @@ docker run -d --name rotki \ rotki/rotki:latest ``` -| Variable | Meaning | -| ---------------------------------- | ------------------------------------------------------------------- | -| `LOGLEVEL` | backend log level | -| `LOGFROMOTHERMODULES` | include third-party library logs | -| `MAX_SIZE_IN_MB_ALL_LOGS` | total log size budget | -| `MAX_LOGFILES_NUM` | rotated log files to keep | -| `SQLITE_INSTRUCTIONS` | SQLite instructions-per-context | -| `ROTKI_HTTP_PORT` | the port rotki serves on _inside_ the container (default `80`) | -| `TZ` | container timezone, see [below](#setting-the-timezone) | -| `ROTKI_SESSION_KEY` | turns on session authentication, see [Security](#security) | -| `ROTKI_ACCEPT_UNAUTHENTICATED_API` | silences the unauthenticated-API warning, see [Security](#security) | +| Variable | Meaning | +| ---------------------------------- | -------------------------------------------------------------------------- | +| `LOGLEVEL` | backend log level | +| `LOGFROMOTHERMODULES` | include third-party library logs | +| `MAX_SIZE_IN_MB_ALL_LOGS` | total log size budget | +| `MAX_LOGFILES_NUM` | rotated log files to keep | +| `SQLITE_INSTRUCTIONS` | SQLite instructions-per-context | +| `ROTKI_HTTP_PORT` | the port rotki serves on _inside_ the container (default `80`) | +| `TZ` | container timezone, see [below](#setting-the-timezone) | +| `ROTKI_SESSION_KEY` | turns on session authentication, see [Security](#security) | +| `ROTKI_ACCEPT_UNAUTHENTICATED_API` | silences the unauthenticated-API warning, see [Security](#security) | +| `ROTKI_SESSION_COOKIE_SECURE` | marks the session cookie `Secure`, see [below](#marking-the-cookie-secure) | Configuration is read once at boot, so changing any of these requires recreating the container. @@ -171,10 +172,10 @@ services: Session authentication closes the open door. It is not a substitute for putting rotki behind something that terminates TLS and authenticates for it. -- **There is no TLS.** The image speaks plain HTTP, and the session cookie is deliberately not marked `Secure` so it works on loopback and a LAN. Your account password on the way in, and the session cookie on every request after, both cross the network in cleartext. Anyone who can observe or redirect traffic between your browser and the container can read them and reuse the cookie. +- **There is no TLS.** The image speaks plain HTTP, and the session cookie is not marked `Secure` by default, so it works on loopback and a LAN. Your account password on the way in, and the session cookie on every request after, both cross the network in cleartext. Anyone who can observe or redirect traffic between your browser and the container can read them and reuse the cookie. Terminating TLS in front of the container fixes the encryption; [marking the cookie `Secure`](#marking-the-cookie-secure) then stops it ever being sent in the clear. - **There is no brute-force protection on sign-in.** Nothing rate-limits or locks out repeated attempts, so the strength of the whole thing is the strength of your account password. - **The pre-login calls above stay open.** Account names remain enumerable and account creation remains ungated, with or without a session key. -- **A session lasts 7 days** unless you sign out or another sign-in takes it over. +- **A session goes stale after a day idle, and can never outlive 7 days.** Using it rolls the idle window forward, so an active session does not expire mid-use, but no amount of use extends it past the 7-day ceiling. Signing out, or signing in elsewhere, ends it immediately either way. ### Use an authenticating reverse proxy @@ -183,7 +184,7 @@ If rotki is reachable by anything other than the machine it runs on, put an auth A proxy fixes exactly what the list above cannot: - it encrypts the connection, so the password and cookie are no longer in the clear; -- it can send `Strict-Transport-Security` (HSTS), which is worth turning on. The session cookie is not marked `Secure`, so if that same hostname is ever reachable over plain HTTP the browser will attach the cookie to those requests too. HSTS stops the browser making a plaintext request to the host at all, which closes that gap. Pair it with an unconditional HTTP to HTTPS redirect, and do not serve rotki on plain HTTP alongside; +- it can send `Strict-Transport-Security` (HSTS), which is worth turning on even after you mark the cookie `Secure`. The two close different halves of the same gap: `Secure` stops the browser _sending_ the cookie over plain HTTP, while HSTS stops it _making_ the plaintext request at all. Without HSTS the request still goes out, just without the cookie. Pair it with an unconditional HTTP to HTTPS redirect, and do not serve rotki on plain HTTP alongside; - it rejects unauthenticated callers **before** they reach rotki, so the pre-login calls stop being exposed at all; - it is where rate limiting, IP allowlisting and tools like fail2ban belong. @@ -192,6 +193,38 @@ The [Traefik + basic auth example](#public-network-with-traefik-basic-auth) belo > [!WARNING] > What must never be public is rotki's **own** port. Behind a proxy you control, that authenticates every request and terminates TLS, reaching rotki over the internet is fine, and that is what the Traefik example is for. The danger is publishing the container's port directly, or putting a proxy in front that forwards without authenticating: rotki was not built to be the thing standing between the internet and your data. If you would rather not run a proxy at all, a VPN into the network the container sits on gets you the same result. +### Marking the cookie `Secure` + +Once TLS is terminated in front of the container, `ROTKI_SESSION_COOKIE_SECURE` marks the session cookie `Secure`, which tells the browser never to send it over a plain HTTP connection. + +It is off by default and has to stay that way: the image itself speaks plain HTTP, and on loopback or a LAN the flag would stop the cookie being sent at all, so nobody could sign in. Only you know whether something in front is terminating TLS. + +| Value | Behaviour | +| ------------------- | ------------------------------------------------------ | +| unset, `0`, `false` | off, the default | +| `1`, `true` | always mark the cookie `Secure` | +| `forwarded` | decide per request from the `X-Forwarded-Proto` header | + +Use `1` when TLS is terminated in front and you know every request reaches the browser over HTTPS. Use `forwarded` when your proxy sets `X-Forwarded-Proto` and you want the flag to follow the actual scheme. An unrecognised value logs a warning and is treated as off, rather than quietly deciding whether a credential may cross a plaintext connection. + +```yaml +services: + rotki: + environment: + - ROTKI_SESSION_KEY=${ROTKI_SESSION_KEY} + - ROTKI_SESSION_COOKIE_SECURE=forwarded +``` + +> [!WARNING] +> If you set `1` while the browser still reaches rotki over plain HTTP, nobody can sign in: the browser refuses a `Secure` cookie that arrives over a plaintext connection, so the cookie is discarded and every request looks unauthenticated. Set it only once TLS actually terminates in front. + +In `forwarded` mode rotki does not simply believe the header. The supervisor rewrites `X-Forwarded-Proto` on every proxied request, keeping an inbound value only when the peer is a trusted hop and overwriting it with `http` otherwise, so a client cannot dictate the value by sending the header itself. Two consequences are worth knowing: + +- **If your TLS terminator reaches the container from a public address, name it with `--trusted-proxy`.** Otherwise its `X-Forwarded-Proto: https` is discarded like any other untrusted peer's and the cookie is silently never marked `Secure`, even though TLS is working. Nothing warns you; sign-in keeps working, just without the flag. +- **Loopback, private and link-local peers are trusted by default**, and that set can only be extended, never narrowed. A client on the same LAN as the container is therefore trusted and can set the header on its own requests. That only affects the cookie in its own response, so it can force `Secure` onto its own session but cannot weaken anyone else's. If you would rather not allow even that, use `1` instead of `forwarded`, and do not publish the port to a network you do not trust. + +`Secure` is not a replacement for HSTS. It stops the cookie being _sent_ over plaintext; only `Strict-Transport-Security` stops the browser making the plaintext request in the first place. Send both from the proxy that terminates TLS. + ### Accepting the risk instead If you know the instance is unreachable by anyone else and you do not want session authentication, you can dismiss the warning permanently: From 486c0fbf5f0722e68e26c8e324a255af119be635 Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Wed, 19 Aug 2026 13:24:52 +0200 Subject: [PATCH 6/7] docs: document the container health check and shutdown The image ships its own HEALTHCHECK and handles SIGTERM, and neither was written down. Operators were left to add a probe that duplicates the built-in one, and a short docker stop timeout kills the backends mid-write. Also fills in the developer side of the same image: docker build needs ROTKI_VERSION or it fails at PyInstaller, and the distroless runtime has no shell, so debugging goes through starling ctl status, docker top, and a busybox image you build yourself. --- .../build-from-source.md | 37 ++++++++++++++++++- requirement-and-installation/docker.md | 19 ++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/requirement-and-installation/build-from-source.md b/requirement-and-installation/build-from-source.md index 682f8b3..05f2dfb 100644 --- a/requirement-and-installation/build-from-source.md +++ b/requirement-and-installation/build-from-source.md @@ -212,9 +212,44 @@ pnpm run dev:web To build a Docker image from source using the repo's `Dockerfile`: ```sh -docker build -t rotki . +docker build \ + --build-arg ROTKI_VERSION=1.44.0 \ + --build-arg REVISION="$(git rev-parse HEAD)" \ + -t rotki . ``` +`ROTKI_VERSION` is not optional. It becomes the backend package's fallback version, and without it the build fails late, during the PyInstaller step, with `InvalidVersion: ''`. Use the version you are building, or any valid version string if you only want an image to test with. `REVISION` is only reported by the running image and can be left out. + +### Debugging a container you built + +The runtime image is distroless. It has no shell, no package manager and no coreutils, so the usual moves do not work: + +```sh +docker exec rotki sh +# OCI runtime exec failed: exec failed: unable to start container process: +# exec: "sh": executable file not found in $PATH +``` + +`ls`, `cat` and friends fail the same way. What does work: + +- `docker exec rotki /opt/rotki/starling ctl status` for the state of the supervised processes. +- `docker top rotki` for the process list. +- Reading the data and log volumes from the host side of the bind mount rather than from inside the container. + +If you genuinely need a shell in there, build yourself a debug image. There is no published `:debug` tag, deliberately, so layer one on top of the image you just built: + +```dockerfile +FROM rotki:latest +COPY --from=busybox:1.37-uclibc /bin/busybox /bin/busybox +``` + +```sh +docker build -t rotki:debug -f Dockerfile.debug . +docker exec -it rotki /bin/busybox sh +``` + +Inside that shell the applets are not on `PATH` as separate commands, so call them through busybox: `busybox ls /opt/rotki`, `busybox cat /logs/rotki.log`. Keep this for local debugging; do not run a busybox-carrying image in production, since it hands anything that gets in the shell the image was built without. + ## Troubleshooting ### Blank screen when running the dev server diff --git a/requirement-and-installation/docker.md b/requirement-and-installation/docker.md index e4da5a8..7263269 100644 --- a/requirement-and-installation/docker.md +++ b/requirement-and-installation/docker.md @@ -264,6 +264,25 @@ The image is built on a distroless base, so there is no shell, no package manage docker exec rotki /opt/rotki/starling ctl status ``` +## Health and shutdown + +### The built-in health check + +The image ships its own `HEALTHCHECK`, so `docker ps` and any orchestrator that reads container health already know whether rotki is serving. You do not need to add one to your Compose file. + +It probes two URLs on the container's own port and requires both to answer: + +- `/health`, the supervisor's view of the processes it manages. This is what notices that colibri has died. +- `/api/1/ping`, which only answers if the proxy is really forwarding to the backend. + +Neither covers the other. `/health` is answered by the supervisor itself, so it keeps reporting `ok` while the path to the backend is broken, and a plain ping never touches colibri at all. + +The probe resolves the port exactly as the server does, so `-e ROTKI_HTTP_PORT=8080` keeps the two in agreement with no further change. The first check is deferred for 60 seconds: on a fresh volume the backend has to build its global database before it can answer, and a container that is legitimately still starting should not be reported unhealthy. + +### Stopping the container + +`docker stop` sends `SIGTERM`, which the supervisor handles: it asks the backends to stop, gives them ten seconds to finish writing, and only then escalates. Allow for that when you set a stop timeout. `docker stop --time 5` cuts the shutdown short and kills the backends mid-write, and the ten second default leaves no margin, so prefer `--time 20`. + ## Setting the timezone Set `TZ` when starting the container: From 595df6673613d395810e19fed3262a336a48a481 Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Wed, 19 Aug 2026 13:33:20 +0200 Subject: [PATCH 7/7] docs: note the root-owned lock file under --user --- requirement-and-installation/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirement-and-installation/docker.md b/requirement-and-installation/docker.md index 7263269..8660c7b 100644 --- a/requirement-and-installation/docker.md +++ b/requirement-and-installation/docker.md @@ -41,7 +41,7 @@ Your account data lives under `~/.rotki/data`; your logs are under `~/.rotki/log > docker run --rm -v $HOME/.rotki/data:/data debian:12-slim rm -rf /data/some-file > ``` > -> If you pass `docker run --user ` the container skips the drop and runs as that uid instead, but then the volumes must already be writable by it. +> If you pass `docker run --user ` the container skips the drop and runs as that uid instead, but then the volumes must already be writable by it. That includes `/data/.starling.lock`, which a previous root-started container left owned by root: if the container now exits reporting that it could not lock the data directory, `chown` that file to the uid you are running as. ## Configuring the backend