# Configuration reference

Every `CASHPILOT_*` setting, what reads it, and, where a file can also supply the value, **which one wins**.

That last column is the reason this page exists. Two settings look identical from the outside (a secret, supplied by an environment variable or by a file under `/data`) and resolve in *opposite* directions. Each behaviour is defensible on its own; together they are impossible to guess.

> [!WARNING]
>
> **The precedence is not uniform, and the differences are deliberate**
>
> - **Credential-encryption key**: the **file wins**. Switching keys would make every stored credential unreadable, so an existing `/data/.fernet_key` beats `CASHPILOT_ENCRYPTION_KEY`, and CashPilot logs loudly when they differ.
> - **Session-signing key**: the **environment wins**. Sessions are cheap to invalidate, so `CASHPILOT_SECRET_KEY` takes precedence and the file is only a fallback.
>
> If you set an environment variable and nothing changed, this is why.

## UI

| Variable | Default | What it does | Precedence |
| --- | --- | --- | --- |
| `CASHPILOT_SECRET_KEY` | generated | Signs session cookies. | **Env wins**, then `/data/.secret_key`, then a generated key that is persisted. A known-placeholder value is ignored. |
| `CASHPILOT_ENCRYPTION_KEY` | generated | Fernet key for credentials at rest. | **File wins.** An existing `/data/.fernet_key` beats this; the env key is adopted only when no file exists. |
| `CASHPILOT_ALLOW_EPHEMERAL_KEY` | `false` | Allow starting when the encryption key cannot be persisted. | \- |
| `CASHPILOT_API_KEY` | from `/fleet` | Shared **enrolment** key. Not an ongoing credential, and it reads nothing: every dashboard API route answers it with 403. Tiles, widgets and scripts use `CASHPILOT_READONLY_API_KEY`. See [Fleet](<https://geiserx.github.io/CashPilot/fleet/index.md>). | Env, else `/fleet/.fleet_key`, else generated there. |
| `CASHPILOT_ADMIN_API_KEY` | unset | Bearer token for API access without a session. Grants **owner**: it can deploy, stop and remove containers and read stored credentials. | \- |
| `CASHPILOT_READONLY_API_KEY` | unset | Bearer token for **reporting only**. Accepted on a small allowlist of GET endpoints (earnings summary and breakdown, fleet summary, health scores, deployed services) and refused everywhere else, including on endpoints added in the future. Use this for a dashboard tile, a Grafana panel or Home Assistant sensors rather than handing them a key that controls containers. | \- |
| `CASHPILOT_DATA_DIR` | `/data` | Where the database and keys live. | \- |
| `CASHPILOT_FLEET_DIR` | `/fleet` | Where the shared enrolment key lives. | \- |
| `CASHPILOT_BASE_URL` | unset | The URL you open CashPilot at. When it starts with `https`, session cookies get the `Secure` flag (unless `CASHPILOT_SECURE_COOKIE` says otherwise). | \- |
| `CASHPILOT_SECURE_COOKIE` | auto | `true` forces the `Secure` cookie flag on, `false` forces it off. Unset, it is on when `CASHPILOT_BASE_URL` is `https` or a trusted proxy reports HTTPS. | \- |
| `CASHPILOT_SESSION_EPOCH` | `0` | A Unix timestamp: sessions issued before it are rejected. Set it to the current time to sign everyone out. | \- |
| `CASHPILOT_TRUSTED_PROXY` | off | Set to `1` (or `true`) only when the UI sits behind exactly one reverse proxy you control. CashPilot then trusts `X-Forwarded-For` for the client address and `X-Forwarded-Proto` for the `Secure` cookie flag. Whatever this is set to, the proxy must pass the original `Host` header or send `X-Forwarded-Host`: a signed-in browser's changes are refused with 403 when its page address matches neither. | \- |
| `CASHPILOT_COLLECT_INTERVAL` | `60` | Minutes between earnings collections. | \- |
| `CASHPILOT_HOSTNAME_PREFIX` | `cashpilot` | Fills `{hostname}` in a service's device name when a deploy names no host. Container names are `cashpilot-<service>`, with `-2`, `-3` and so on for extra copies on the same box. | \- |
| `CASHPILOT_VERSION` | `dev` | Set by the image build. Shown in the sidebar. | \- |
| `CASHPILOT_METRICS_ENABLED` | `false` | Serve `/metrics`. | \- |
| `CASHPILOT_METRICS_TOKEN` | unset | Require `Authorization: Bearer` on `/metrics`. | \- |
| `CASHPILOT_DRIFT_WARNINGS` | `on` | Set to `off` to stop judging containers against the catalog: no "Needs fixing" row for a container that misses a catalog change. Set it on the worker too, which then stops reporting container settings. A single warning can be muted from its row instead (Ignore). Containers you made by hand are never judged. | \- |
| `CASHPILOT_UPDATE_CHECK` | `on` | Set to `off` to disable the once-a-day check for a newer release. See below. | \- |
| `CASHPILOT_NTFY_URL` | unset | ntfy endpoint for alerts. | \- |
| `CASHPILOT_WEBHOOK_URL` | unset | Generic webhook for alerts. | \- |
| `CASHPILOT_TELEGRAM_BOT_TOKEN` | unset | Telegram alerts. | \- |
| `CASHPILOT_TELEGRAM_CHAT_ID` | unset | Telegram alerts. | \- |
| `CASHPILOT_WORKER_ALLOWED_HOSTS` | unset | Worker hosts allowed under the `strict` policy: CIDRs, host names and `*.suffix` patterns, separated by commas. | \- |
| `CASHPILOT_WORKER_ALLOW_METADATA` | `false` | Allow proxying to cloud metadata IPs. Leave off. | \- |
| `CASHPILOT_WORKER_URL_POLICY` | `permissive` | How worker URLs are validated. `permissive` accepts any address except loopback, link-local and cloud metadata, so LAN, Tailscale and public addresses all pass. `strict` accepts only `CASHPILOT_WORKER_ALLOWED_HOSTS`. | \- |
| `CASHPILOT_ACTIVITY_ASN_URL` | iptoasn.com's `ip2asn-combined.tsv.gz` | Where the [Activity](<https://geiserx.github.io/CashPilot/activity/index.md>) page's network and country table is downloaded from, weekly while a box has Activity on. Empty disables the download; networks and countries then read as unknown unless you place `/data/ipgeo.sqlite` by hand. | \- |

## Worker

| Variable | Default | What it does | Precedence |
| --- | --- | --- | --- |
| `CASHPILOT_UI_URL` | \- | **Required.** Where to send heartbeats. | \- |
| `CASHPILOT_API_KEY` | \- | **Required for enrolment only.** After enrolling, the worker uses its own key from `/data/.worker_key`. | \- |
| `CASHPILOT_WORKER_NAME` | hostname | Display name. **Set it.** Inside a container the default is the container ID, which Docker regenerates on every recreate. | \- |
| `CASHPILOT_WORKER_URL` | detected | The URL this worker **advertises**. | \- |
| `CASHPILOT_PORT` | `8081` | The port this worker **advertises**; see the note below. | \- |
| `CASHPILOT_WORKER_NETWORK` | detected | `residential` or `hosting`. | \- |
| `CASHPILOT_DRIFT_WARNINGS` | on | `off` stops reporting each container's settings, so the UI judges none of this worker's containers against the catalog. | \- |
| `CASHPILOT_EGRESS_DETECT` | on | Hourly public-IP lookup. `off` disables it. | \- |
| `CASHPILOT_EGRESS_IP` | unset | State the public IP directly. A LAN or tailnet address is rejected. | \- |
| `CASHPILOT_EGRESS_IP_URL` | unset | Custom IP-echo endpoint. | \- |
| `CASHPILOT_ALLOWED_VOLUME_ROOTS` | unset | Host paths a deploy may bind-mount. | \- |
| `DOCKER_HOST` | set by compose | Where the worker reaches Docker. The shipped compose files point it at the [socket proxy](<https://geiserx.github.io/CashPilot/security-defaults/#docker-socket-proxy>) (`unix:///run/cashpilot-docker/docker.sock`); unset, the worker uses `/var/run/docker.sock`. | \- |
| `CASHPILOT_PIDS_LIMIT` | `512` | `pids` limit applied to managed containers. A value that is not a positive integer falls back to the default. | \- |
| `CASHPILOT_ISOLATION` | `auto` | `auto`, `on` or `off`. With `auto`, a new install fences its earners off your LAN, the host, your tailnet and cloud metadata, and an upgraded install whose earners are on Docker's default bridge keeps them there until you turn isolation on from the Fleet page. `on` fences from the start; `off` keeps Docker's default bridge. See [LAN isolation by default](<https://geiserx.github.io/CashPilot/isolation/#lan-isolation-by-default>). | \- |
| `CASHPILOT_FENCE_BACKEND` | `auto` | For testing only: `legacy` makes the fence use `iptables-legacy` on a host that has nf\_tables, `nft` forces nf\_tables. `auto` picks nf\_tables when the kernel has it. | \- |
| `CASHPILOT_CONTAINER_NETWORK` | unset | A bridge network you created, which containers that would land on Docker's default bridge join instead, so your own host firewall rules can confine them. Setting it turns off CashPilot's own fence on that worker. Host-networked services are unaffected. The worker never creates it; a deploy is refused while it is missing. See [Protecting your home network](<https://geiserx.github.io/CashPilot/home-network-security/index.md>). | \- |
| `CASHPILOT_CONTAINER_DNS` | `1.1.1.1,9.9.9.9` | DNS servers, IP addresses separated by commas, for containers on CashPilot's isolated network or on `CASHPILOT_CONTAINER_NETWORK`. Other containers and the host keep the Docker daemon's list. Set it empty to give the isolated containers the daemon's list too. Applies when a container is next deployed. See [name lookups](<https://geiserx.github.io/CashPilot/home-network-security/#name-lookups-need-a-resolver-the-rules-allow>). | \- |
| `CASHPILOT_CONTAINER_RUNTIME` | unset | A Docker runtime, such as a gVisor runtime you registered in `daemon.json`, for containers on bridge networking. Host-networked services keep Docker's default. A deploy is refused while the daemon does not provide it. See [gVisor](<https://geiserx.github.io/CashPilot/home-network-security/#7-gvisor-optional>). | \- |
| `CASHPILOT_CONTAINER_RUNTIME_OVERRIDES` | unset | Per-service exceptions, `slug=runtime` separated by commas, e.g. `bitping=runsc-hostnet-raw`. `slug=runc` runs that service under `runc`, the runtime Docker ships with, whatever the daemon's default is. | \- |
| `CASHPILOT_TUNNEL_IMAGE` | `drumsergio/cashpilot-tunnel:<worker version>` | The tunnel image for [proxied copies](<https://geiserx.github.io/CashPilot/proxies/index.md>), for a mirror or a test build. Only an exact version tag or a digest is accepted, never `latest`. | \- |
| `CASHPILOT_DATA_DIR` | `/data` | Where `.worker_id` and `.worker_key` live. | \- |

> [!CAUTION]
>
> **`CASHPILOT_PORT` does not change the port the worker listens on**
>
> The listen port is fixed at `8081` by the image's `CMD`. `CASHPILOT_PORT` only changes the port the worker **advertises** to the UI. Setting it alone makes the worker advertise a port nothing is listening on, and the UI's container commands then fail with nothing in the logs connecting the two.
>
> To actually move the port, override the container's `command:` **and** set `CASHPILOT_PORT` to match.

## GPU passthrough

CashPilot reports a worker's GPU as one of three answers, **yes**, **no** or **unknown**, and inside a container the honest answer is almost always *unknown*: the absence of a GPU there says nothing about the host.

That matters because four services only earn with a real GPU (Salad, Nosana, io.net, Vast.ai), and a GPU service deployed **without** the device starts, reports healthy, and earns nothing. It is the same shape as the Mysterium `/dev/net/tun` failure.

To let the worker see an Intel or AMD GPU, uncomment the block in the compose file:

```
devices:
  - /dev/dri:/dev/dri
```

> [!WARNING]
>
> **Only on a host that actually has one**
>
> Docker **refuses to start a container** when a listed device does not exist, so this is shipped commented out. Uncommenting it on a GPU-less host breaks the worker outright.

### NVIDIA is a different mechanism

`/dev/dri` does nothing for an NVIDIA card, and installing the [NVIDIA Container Toolkit](<https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html>) is only the **prerequisite**. The toolkit on its own does *not* hand the GPU to a Compose service. You have to ask for it as well, with **either** of these:

```
gpus: all
```

```
deploy:
  resources:
    reservations:
      devices:
        - driver: nvidia
          count: all
          capabilities: [gpu]
```

Both are Compose-spec attributes and both were validated against Compose v2.40.3. Pick one; do not set both. Use the explicit reservation when you need to pin particular cards, which it can do via `device_ids` and `capabilities`.

> [!WARNING]
>
> **These fail on a GPU-less host too**
>
> Verified: on a machine with no NVIDIA card, *both* forms exit 1, the reservation form reporting `could not select device driver`. So like `/dev/dri`, they ship commented out rather than enabled by default.

Once the GPU is actually allocated, the worker finds `nvidia-smi` and reports the real model name rather than just a device count.

Passing a device into the worker only lets the **worker** see it. A deployed GPU **service** needs the device too. Declare it in that service's catalog entry.

## Compose-level

These are read by the compose files, not by CashPilot itself.

| Variable | Default | What it does |
| --- | --- | --- |
| `CASHPILOT_BIND_ADDR` | `127.0.0.1` | Which host interface publishes the **UI** port. |
| `CASHPILOT_UI_PORT` | `8080` | Which host port publishes the **UI**. Set it when 8080 is already taken on the host. |
| `CASHPILOT_WORKER_BIND_ADDR` | `127.0.0.1` | Which host interface publishes the **worker** port, in the fleet and worker-only compose files. The worker's API can create containers on the host, so publish it only on an interface the UI needs, never `0.0.0.0`. |
| `CASHPILOT_COMPOSE_DIR` | unset | Your compose folder. The [socket proxy](<https://geiserx.github.io/CashPilot/security-defaults/#docker-socket-proxy>) lets containers bind it, which the in-app update needs. Unset, the **Update** button refuses and prints the line to add. |
| `CASHPILOT_PROXY_BIND_MOUNTS` | unset | Other host folders the socket proxy lets containers bind, comma-separated with no spaces: the data folder of a service you deploy with a host path, such as Storj (`/mnt/user/storj`). Each entry also counts as a `CASHPILOT_ALLOWED_VOLUME_ROOTS` entry, so this one setting opens the folder for both the worker and the proxy. A deploy outside them is refused before anything is touched, with a message naming this variable. |
| `CASHPILOT_PROXY_POLICY` | set by the compose file | The fingerprint of the socket proxy policy in the same compose file, written by `scripts/socket_proxy_policy.py`; the worker and the proxy both carry it. Do not edit it. The worker compares it with the policy its own catalog needs and, when they differ, its log, its status page and the update card give the command that updates the policy in your compose file ([how](<https://geiserx.github.io/CashPilot/security-defaults/#docker-socket-proxy>)). |
| `CASHPILOT_MIGRATE_COMPOSE_VOLUMES` | set by the compose file | Read by the one-shot `cashpilot-migrate` service: pairs `old=new` of compose volumes whose data it copies, once, into the renamed volume that carries the worker's owner label. The old volume is kept. Do not edit it. See [UPGRADING.md](<https://github.com/GeiserX/CashPilot/blob/main/UPGRADING.md>). |

The UI's web port inside the container is fixed at `8080` (set via the container's `CMD`). `CASHPILOT_UI_PORT` and `CASHPILOT_BIND_ADDR` choose only the host side: which port and which interface it is published on.

Put these in a `.env` file next to the compose file, not on the command line. The **Update** button in **Settings** reads `.env` but never sees your shell. If the published ports came from the command line, it refuses to update rather than move the dashboard back to `127.0.0.1:8080`.

## Memory

**The shipped compose files set no memory limit, deliberately.** If you set one, size it from the numbers below rather than from what the container looks like when idle, because those two figures are very far apart.

### Measured, not estimated

Read from the kernel's own high-water mark (`memory.peak`) on a live install after 8 hours of normal operation, which covers roughly 8 hourly collection cycles:

| Container | Steady state | **Peak** | Database |
| --- | --- | --- | --- |
| `cashpilot-ui` | \~72 MiB | **207 MiB** | 54.7 MB |
| `cashpilot-worker` | \~65 MiB | **130 MiB** | \- |

*Provenance: `drumsergio/cashpilot:1.14.1`, 15 managed containers, 54.7 MB SQLite database, collectors running hourly in-process under APScheduler. Figures are `memory.peak` and `docker stats` from the container's own cgroup.*

### Why the peak is what matters

The UI idles around **72 MiB and peaks near 207 MiB**, close to three times its resting size. Collection runs every service's collector in-process, and the transient cost of that dwarfs the steady state.

So a limit chosen by looking at a running container is almost certainly too low. **128 MiB looks generous against 72 MiB and will be exceeded on the first collection cycle.**

> [!CAUTION]
>
> **An OOM here does not look like an OOM**
>
> The container is killed and restarted mid-collection. The dashboard keeps serving the last figures it stored, so nothing on screen says anything is wrong. The symptom is "earnings stopped updating", days later, which is the hardest kind of failure to notice and the hardest to report.
>
> That is the reason this page gives you a number instead of a limit.

### If you do set one

Leave real headroom above the measured peak. **256 MiB leaves only 49 MiB above the 207 MiB observed here**, on one install with one database size. A larger database, more services, or a slower provider that holds connections longer will all push it up.

```
services:
  cashpilot-ui:
    mem_limit: 384m     # 177 MiB above the measured peak
  cashpilot-worker:
    mem_limit: 256m     # 126 MiB above the measured peak
```

Re-measure on your own install rather than trusting these:

```
docker exec cashpilot-ui cat /sys/fs/cgroup/memory.peak
```

## Running a service more than once on one worker

Open the service and set **Instances** next to a node. Each extra copy is its own container (`cashpilot-<service>-2`, `-3`, ...) and registers with the provider as its own device: every device name the catalog derives from the host name gets `-2`, `-3` added.

- Providers count devices per public IP, and copies on one machine share its connection. Extra copies add devices, not bandwidth, so they only help where the provider pays per device and allows several per IP (Traffmonetizer and Mysterium document that). Where a provider allows one device per IP, the box is locked at 1; add workers on other connections instead.
- A service that keeps data in a host directory or publishes a host port runs once per worker, because its copies would share the directory and fight over the port.
- The count is saved per worker and kept by every redeploy. Lowering it removes the highest copies first; removing the service from a worker removes all of them.
- Each copy has its own Restart, Stop and Logs under **Deployed on** in the service's details and on the dashboard.
- A worker runs more than one copy only once it is on a release that supports it; older workers keep the box locked until they are updated.

## A service's image

Each service runs from the image its catalog entry names. To run it from another one, open the service, expand **Advanced** and type the image in the **Image** box: a pinned tag of an image the catalog leaves floating (`honeygain/honeygain:0.8.1`), a registry mirror, or a community build for an architecture the provider does not publish.

- Blank means the catalog image. Clearing the box and deploying goes back to it.
- The choice is saved per worker with the rest of the deployment, and every redeploy keeps it until you clear the box. **Redeploy running instances with this image** applies it to the workers that already run the service, keeping their credentials.
- Only the image changes. Capabilities, devices, ports, the entrypoint and the stop timeout still come from the worker's catalog entry, so a custom image gets nothing the service does not already have. It does receive the service's credentials, so use an image you trust.
- An image that boots systemd (its main process is `/sbin/init`, and its own docs ask for `--privileged`) is refused at deploy time with that reason: it needs the host's cgroup tree, which CashPilot never grants, so it would exit 255 and restart forever with empty logs. Pick the image's non-systemd tag instead, usually `lite` (`venatum/earnapp:lite`, never `venatum/earnapp`).
- The dashboard marks such an instance **custom image** and judges "update available" against the image you chose, not the catalog's.

To run an image the catalog does not list at all, add it as a [custom service](<https://geiserx.github.io/CashPilot/custom-services/index.md>) instead.

## Update check

CashPilot asks GitHub once a day whether a newer release exists, and shows a dismissible banner if there is one. A fleet running 33 releases behind with no indication anywhere is the problem this solves.

Three things it deliberately does not do:

- **It never updates anything.** It tells you; you decide. This application deploys containers and holds credentials, and nothing about that should happen because a version number changed.
- **It never says "up to date".** Offline, firewalled, disabled, or simply not run yet all produce *unknown*, and unknown renders nothing at all: no error, no spinner, and no reassurance it has not earned.
- **It sends nothing about you.** One unauthenticated `GET` to the public releases endpoint. No request body, no identifier, and your version is not reported upstream. GitHub learns that an IP asked what the latest release is.

Turn it off entirely and no connection is made:

```
environment:
  - CASHPILOT_UPDATE_CHECK=off
```

The banner is dismissible per version. Dismiss `v1.20.1` and it stays gone until there is a `v1.20.2`, so it cannot become wallpaper.

## Themes

Every signed-in user picks a theme in the avatar menu (top right). It applies straight away and is saved to that account, so it follows you to any browser. A user who never picked one gets Classic night.

![The avatar menu open on a phone, with the theme list: Classic night (default), Dark, Light, Sunrise and Warm, each with a colour swatch](<https://geiserx.github.io/CashPilot/images/screenshots/theme-picker.png>)

| Theme | What it looks like |
| --- | --- |
| Classic night (default) | Blue-black, violet hairlines, rose buttons and a cyan focus ring. |
| Dark | Graphite and white, no accent colour: colour only ever means a status. |
| Light | The dark theme's rules on paper white. |
| Sunrise | Dark, with the logo's sunrise gradient at the edges: a line under the top bar, the active tab, the main buttons, the focus ring and the balance. |
| Warm | Dark, with one sun orange on everything you click and the charts in the logo's colours. The warning amber leans yellow so it is not mistaken for the orange. |

Status colours (green up, amber warning, red down) mean the same thing in every theme, and every text and control meets WCAG AA contrast.

![The dashboard in Classic night](<https://geiserx.github.io/CashPilot/images/screenshots/theme-classic.png>)

![The dashboard in Dark](<https://geiserx.github.io/CashPilot/images/screenshots/theme-dark.png>)

![The dashboard in Light](<https://geiserx.github.io/CashPilot/images/screenshots/theme-light.png>)

![The dashboard in Sunrise](<https://geiserx.github.io/CashPilot/images/screenshots/theme-sunrise.png>)

![The dashboard in Warm](<https://geiserx.github.io/CashPilot/images/screenshots/theme-warm.png>)

To add a theme, add one entry to `app/themes.json` and one block plus one swatch rule in `app/static/css/style.css`. The server, the settings check, the menu and the CI checks all read that list.
