Skip to content

Running the daemon in a container

This page is about running the daemon and its admin page in Docker, on a machine that is not your desk (a NAS, a server, a small box under the TV). The daemon reads the providers' usage data and serves it to the Presto display and to the admin page.

Nothing here changes the daemon's rules: the app only observes provider accounts, it never spends or resets anything, and it never reads a credential it was not given.

What is in the box

File What it is
Dockerfile The image: the daemon, its admin page and one dependency.
compose.yaml The daemon, its ports, its data volume and its settings.
.dockerignore The list of files that may not go into the image.
.env.example A template for the tokens. Copy it to .env.

The image is built on python:3.13-alpine3.24, pinned to one digest, and it runs as an unprivileged user (dash, uid 10001). It is about 95 MB.

Quick start

First get the two files the steps below use, compose.yaml and .env.example, into an empty folder on the machine that will run Docker. Each release carries them, so no checkout is needed:

mkdir presto-ai-dash && cd presto-ai-dash
curl -fLO https://github.com/jake1164/presto-ai-dash/releases/latest/download/compose.yaml
curl -fL -o .env.example https://github.com/jake1164/presto-ai-dash/releases/latest/download/default.env.example

The -f makes curl stop with an error instead of saving a "Not Found" page as your file. (GitHub drops the leading dot of an attached file's name, so the template is published as default.env.example. The command saves it as .env.example.)

# 1. Two tokens: one for the admin page, one for the Presto device. Both are
#    required, and they must be different. Generate each (run it twice):
openssl rand -hex 24
#    or: python -c "import secrets; print(secrets.token_urlsafe(24))"

# 2. Put them in .env next to compose.yaml.
cp .env.example .env
#    then edit .env and paste one token after DASH_ADMIN_TOKEN= and the other
#    after DASH_SHARED_TOKEN=. Neither may be left empty. The same file sets
#    the addresses and ports the two services are published on (see below).

# 3. Pull the published image and start.
docker compose up -d

# 4. Open the admin page on this machine and unlock it.
#    http://127.0.0.1:8788

The admin token you generated in step 1 is the token for the first login. It is the permanent one: it does not change when the container restarts. The daemon never prints it.

You cannot leave DASH_ADMIN_TOKEN empty in a container. A local install (not Docker) generates a temporary token at every start when you give it none, and prints it in its log, because its admin page is only reachable from the machine itself. In a container the admin page has to listen on all interfaces, because that is how the published port reaches it, and a non-loopback address needs a token you chose. A generated one would also sit in Docker's log after the container is gone. So Compose refuses to start with an empty token:

required variable DASH_ADMIN_TOKEN is missing a value: DASH_ADMIN_TOKEN is empty
in .env: put a token you generate there (openssl rand -hex 24)

If a token reaches the daemon some other way and is still missing, the daemon stops with:

daemon startup failed: the admin page would listen on 0.0.0.0, which is not a
loopback address; set an explicit admin token (admin.token in dash.toml or
DASH_ADMIN_TOKEN) before listening on a non-loopback address

A second message, of the same kind, appears when the list of names the page answers to (DASH_ADMIN_ALLOWED_HOSTS) is empty.

The device token is separate. The Presto display holds it, and the compose file refuses to start without DASH_SHARED_TOKEN in .env. That is on purpose: without one the daemon would print a temporary token in its log, and Docker keeps that log after the container is gone. Put the same value in the Presto's settings (presto/config.py).

The ports

Port Published as Who uses it
8787 0.0.0.0:8787 (every address) The Presto display polls this.
8788 127.0.0.1:8788 (this machine only) The admin page.

Both are set in .env (the template lists all four with their defaults), so you never edit compose.yaml:

Variable Default What it sets
DASH_PUBLISH_ADDRESS 0.0.0.0 The host address the device port listens on
DASH_DEVICE_PORT 8787 The host port for the device
DASH_ADMIN_PUBLISH_ADDRESS 127.0.0.1 The host address the admin page listens on
DASH_ADMIN_PUBLISH_PORT 8788 The host port for the admin page

Only the host side changes: inside the container the daemon keeps 8787 and 8788. After editing .env, run docker compose up -d again.

The admin page is published on the loopback address of the host on purpose. To reach it from another machine without changing that, use an SSH port forward:

ssh -L 8788:127.0.0.1:8788 user@server

and open http://127.0.0.1:8788 on your own machine. The browser then uses 127.0.0.1:8788, which the admin page always accepts.

To reach the admin page from another machine on the LAN, put two lines in .env (the second keeps the page's own names in the list):

DASH_ADMIN_PUBLISH_ADDRESS=0.0.0.0
DASH_ADMIN_ALLOWED_HOSTS=localhost:8788,127.0.0.1:8788,toontown-int-srv1:8788

Use the name (or address) and port the browser will type. Read the security notes below before you do that.

The health check

The image's health check asks one question:

curl -s http://127.0.0.1:8787/healthz
{"ok": true}

That is the whole answer. The route needs no token and returns nothing else: no version, no source names, no counts, no timings. Every other route still needs its token.

Data and backups

Everything that has to survive an upgrade lives in one named volume, dash-data, mounted at /data:

File What it is
/data/dash.db The SQLite database: the accounts, the history, the encrypted provider keys, the browser sessions.
/data/dash.db.key The encryption master key, in an owner-only file.

Both files belong to the container's dash user and are readable by that user only. To read or back them up from the host, use docker cp or a container running as the same user; a bind mount owned by another host user will not let the daemon create them.

Losing the key file loses the stored provider keys. The database still opens and every other setting is still there, but the encrypted keys cannot be read any more, and they are never restored by guessing: the daemon refuses to start with a wrong or missing key instead of overwriting anything. Back up the database and the key file, together:

docker run --rm -v presto-ai-dash_dash-data:/data -v "$PWD":/backup \
  presto-ai-dash:local tar czf /backup/dash-backup.tar.gz -C /data .

To move to another machine, copy both files and set DASH_ENCRYPTION_KEY instead (see below).

The encryption master key can also live in the environment, which is the portable choice: the key file of a Windows install is protected with DPAPI and cannot be used in a Linux container.

python -m presto_ai_dash.app --generate-key   # prints a Fernet key

Put it in .env as DASH_ENCRYPTION_KEY=.... Keep it with the database backup: the same rule applies, the key and the data belong together.

Upgrades

docker compose pull
docker compose up -d

The image is ghcr.io/jake1164/presto-ai-dash. Its latest tag moves to every release, so the two commands above are the whole upgrade. Each release also keeps an exact tag, for example 0.50.0. To stay on one version, set DASH_IMAGE in .env, for example DASH_IMAGE=ghcr.io/jake1164/presto-ai-dash:0.50.0.

The daemon tells you when a newer release is out (the admin page's Status tab, and a blue arrow on the Presto). It asks only the GitHub Container Registry, compares against the exact release your running image carries, and never pulls or restarts anything. If you pinned DASH_IMAGE, change it to the version the page names first; a pull on its own only downloads the image, and the container keeps running the old one until docker compose up -d dash recreates it. A release candidate image is never what latest points to.

To build the image from a checkout instead of pulling it:

docker compose -f compose.yaml -f compose.build.yaml up -d --build

The data volume is not touched by an upgrade: the database and the key file stay where they are. The daemon migrates its own schema when it starts.

Take a backup first if the version jumps a long way. The daemon refuses to open a database that is newer than it is, and it never downgrades one.

The time zone

A container starts in UTC. The quiet hours and the weekly reset estimate use the process's local time, so set TZ in your .env to your own zone, for example America/New_York or Europe/London. Left alone it is UTC, and quiet hours such as 22:00 to 07:00 will then be in UTC:

environment:
  TZ: "${TZ:-UTC}"

The admin page's Settings tab shows the zone in the effective configuration, next to the other read-only values, so you can see what the process is using.

A reverse proxy in front

The daemon speaks plain HTTP for everything but one thing: the Presto downloads app updates over TLS, on the same device port, with a certificate the daemon makes for itself (see below). A reverse proxy can put TLS in front of the admin page:

  • point the proxy at the container's port 8787 (the device port) or 8788 (the admin page),
  • terminate TLS at the proxy,
  • and add the name the browser will use to the admin page's allowed hosts, because the admin page only answers to the names it knows:
# in .env
DASH_ADMIN_ALLOWED_HOSTS=localhost:8788,127.0.0.1:8788,dash.example.com

With that name in the list, the admin page answers to dash.example.com over HTTP and over HTTPS, and to nothing else. A request with another Host header is refused, exactly as before.

The device port needs no such list: the Presto sends the shared token and the daemon does not look at the Host header there.

Keep the Presto on the daemon's own network, not behind the proxy. The Presto is supported talking to the device port directly. Behind a proxy:

  • App updates over the air stop working when the proxy terminates TLS or speaks HTTP on the device port. The Presto trusts only the certificate authority its daemon gave it at setup, never the proxy's certificate.
  • The Presto's status polls are plain HTTP, so reaching the daemon through a proxy across the internet would send the device token and your quota readings unencrypted.

Passing the device port through as raw TCP might work, but it is not tested or supported.

The update certificate

On first start the daemon makes a private certificate authority and keeps it in dash.db.ota.pem, beside the database (in the container, in the /data volume). It is private to the account that runs the daemon, like dash.db.key. Each start signs a fresh server certificate from it. The setup utility gives each Presto the authority's public certificate. Deleting the file makes a new authority, and every Presto then needs one run of Set up a Presto before it can update over the air again.

Security notes

Securing a deployment covers these in full, including publishing the status port on one LAN address only.

  • The admin page is on the loopback address of the host by default. 127.0.0.1:8788 is reachable from the machine and from nowhere else.
  • The shared token is plain HTTP on the LAN. The Presto device and the device port send it unencrypted. Anyone who can watch the LAN can read it, and with it the status payload. Keep the LAN trusted, or put the device port behind a proxy with TLS, or use a private network such as Tailscale.
  • The admin token is separate from the device token and the daemon refuses to use the same value for both.
  • Limits are per address. The daemon limits how many connections, and how many alert writes, one client address may use. Where every client appears to come from one address (Docker Desktop on Windows or macOS, or a proxy in front), they share one allowance.
  • The container never sees your Claude credentials. Do not mount ~/.claude or ~/.claude.json into it. Those files hold credentials, and the daemon has no reason to read them in a container. The image's build context excludes them, and the test suite fails if it ever stops doing so.
  • The container runs as an unprivileged user and owns only /data. The compose file also drops every Linux capability, forbids gaining new privileges, and mounts the root filesystem read-only (with a scratch /tmp).
  • Tokens can come from files instead of the environment. Anything in environment: shows in docker inspect. For the two tokens and the master key, set DASH_ADMIN_TOKEN_FILE, DASH_SHARED_TOKEN_FILE or DASH_ENCRYPTION_KEY_FILE to a path, and mount the file there (Compose secrets: appear under /run/secrets/). Setting both a variable and its _FILE form is an error, so in compose.yaml replace the DASH_ADMIN_TOKEN and DASH_SHARED_TOKEN lines rather than adding to them:
environment:
  DASH_ADMIN_TOKEN_FILE: /run/secrets/admin_token
  DASH_SHARED_TOKEN_FILE: /run/secrets/shared_token
secrets:
  - admin_token
  - shared_token

with a top-level secrets: block that points each name at a file outside the repository. The daemon reads the whole file (up to 4 KB) and drops the trailing newline. - The image carries no secrets. The configuration, the tokens and the database all come from the environment and the volume.

What works in a container, and what does not

Source In a container? Why
OpenCode Go Yes It is an HTTP API on the internet.
Ollama Cloud Yes It is an HTTP API on the internet.
LiteLLM Yes Point it at the gateway: http://host.docker.internal:4000 or the gateway's LAN address.
Ollama (a server on your machine) Yes Same: http://host.docker.internal:11434 or the LAN address.
Claude Code quota (the terminal export) Through a collector The export runs on the machine where Claude Code runs and reads files there. Use the remote collector below.
Claude Code activity (the local session logs) Through a collector The reader opens the session files on the machine where Claude Code runs. A container has none; the collector's watch mode pushes the summary.
Claude Code usage cache (~/.claude.json) Through a collector Same: the file is on the Claude machine, and it must not be mounted into the container. watch pushes the numbers and nothing else from it.
Codex Through a collector The source needs a local codex app-server and the local login, which a container does not have. The collector's watch mode reads Codex on the machine where it is signed in and pushes the limits.

A source whose files are missing does not break anything: it says plainly that it is unavailable, and why, in its status. The container does not crash over it.

host.docker.internal is the name Docker Desktop gives the machine that runs Docker. Inside the container localhost is the container itself, so the Add source form fills in host.docker.internal for Ollama and LiteLLM and says why. On Linux, add it yourself in the compose file:

extra_hosts:
  - "host.docker.internal:host-gateway"

The LAN address of the machine works everywhere and needs nothing.

Feeding Claude data to a containerised daemon

The way in is the remote collector, the same one the local install uses:

  1. Turn the feature on: Settings -> Remote reports -> Accept remote reports in the admin page, or DASH_INGEST_ENABLED=1 in .env. The .env value locks the checkbox, so use one or the other.
  2. On the same panel, download the collector presto_collector.py. It is a single file, Python 3.8 or newer, and it runs on the Claude machine. The panel shows its SHA-256 to compare after you copy it.
  3. Under Accounts that report remotely, press Create remote token for the Claude account. The token is shown once. The daemon stores only its hash.
  4. On the Claude machine, set that profile's statusLine command to the snippet the page gives you, and point the URL at the container's ingest address (http://server:8787/api/v1/ingest) and the token file at the token you copied.

Claude Code then runs the collector after each response, and the reading arrives in the container. The body carries quota numbers, hashes and a short host label. No credentials, no prompts, no transcripts.

That is the terminal's status line. Two things have no status line: the VS Code panel and Codex. For those the collector has a watch mode, which reads on a timer and pushes with the account's own token, from a folder of its own (~/.presto-ai-dash/collector.json). You do not write it: when you create the token, press Download setup file (check the address first), download presto_collector.py beside it, and on that computer run python presto_collector.py setup. It finds the download, saves your settings and the token, deletes the download and checks once. Read Remote collectors for it, and for what the collector says when the container is not accepting reports yet.

Two warnings:

  • The ingest route is plain HTTP. The token travels in the clear, like the device token. Never expose the device port to an untrusted network; use a reverse proxy with TLS or a private network.
  • A remote token can only write readings for one account. It cannot read the status payload, list your accounts, or touch anything else.

The activity logs and the usage cache are pushed by the collector's watch mode, not by the status line, and so is Codex: the Codex source needs the Codex app-server and its login on the machine it runs on, and a container has neither. Run watch on the machine where Codex is signed in, and the container shows what it reads. The collector never puts a login in the container: it reads the limits there and sends only the numbers.

None of this has been run against a real second computer yet; see Remote collectors for exactly what has been tested.

Seeing the Presto screen without a Presto

The image carries the Presto app and an emulated 240x240 screen, so you can try the container, and click through the cards, without a physical device. It is off by default. To turn it on, add this to .env and recreate the container (docker compose up -d):

DASH_PRESTO_PREVIEW=1

Then open http://127.0.0.1:8790/. The screen shows your real accounts, polled from this daemon as a device named psl, which also appears on the Status tab. Tick Demo on the page for sample data instead.

It is a test aid, not a feature to leave on. The page has no sign-in and shows live account names and quotas, so Compose publishes its port on 127.0.0.1 only. If you change the published port, set DASH_PRESTO_PREVIEW_ALLOWED_HOSTS in .env to the new host:port names (for example localhost:18790,127.0.0.1:18790), as the page refuses any other address. On a server, reach it with an SSH tunnel rather than by publishing it.

The environment variables

The compose file passes the documented variables through, so .env can hold any of them:

Variable What it does
DASH_SHARED_TOKEN The token the Presto device holds. Required by the compose file.
DASH_IMAGE The image to run. ghcr.io/jake1164/presto-ai-dash:latest (the newest release) unless you set it, for example to a version.
DASH_PUBLISH_ADDRESS The host address the device port is published on. Every address by default; set your LAN address on a host with a public interface.
DASH_DEVICE_PORT The host port for the device. 8787.
DASH_ADMIN_PUBLISH_ADDRESS, DASH_ADMIN_PUBLISH_PORT The host address and port the admin page is published on. 127.0.0.1 and 8788.
DASH_ADMIN_TOKEN_FILE, DASH_SHARED_TOKEN_FILE, DASH_ENCRYPTION_KEY_FILE A path to a file holding the value, instead of the variable. Not both.
DASH_HOST / DASH_PORT The device port. 0.0.0.0:8787 inside the container.
DASH_ADMIN_HOST The admin page's bind address. 0.0.0.0 inside the container.
DASH_ADMIN_ALLOWED_HOSTS The names the admin page answers to, comma separated. localhost and 127.0.0.1 on DASH_ADMIN_PUBLISH_PORT unless you set it in .env.
DASH_ADMIN_TOKEN The admin page's token. Required for a non-loopback bind address.
DASH_ADMIN_SECURE_COOKIE true when a TLS proxy fronts the admin page, so the session cookie is marked Secure.
DASH_DATABASE The database. /data/dash.db.
DASH_KEY_FILE The key file. /data/dash.db.key.
DASH_ENCRYPTION_KEY The encryption master key, instead of the key file.
DASH_INGEST_ENABLED The remote reports switch. Unset by default, so you can tick Accept remote reports in the admin page. Set to 1 or 0 in .env to fix the choice; the checkbox is then locked.
DASH_PRESTO_PREVIEW 1 serves the Presto screen in a browser at port 8790, on this machine only. Off unless set.
DASH_PRESTO_PREVIEW_ALLOWED_HOSTS The host:port names the preview answers. Change it only with the published port.
DASH_DEMO Demo mode: sample data instead of the real accounts. Off unless set to 1.
DASH_LOG_LEVEL INFO by default.
DASH_OVERFLOW_ROWS The Home overflow strip. 1 by default.
TZ The time zone of the process.

Troubleshooting

The container will not start. Read its log:

docker compose logs dash

The two messages you are most likely to see are the two refusals above: no admin token, or no allowed hosts.

The admin page answers 403. The name in the browser's address bar is not one the page knows. Add it to DASH_ADMIN_ALLOWED_HOSTS, including the port the browser used (127.0.0.1:8788), and restart the container.

The Presto says "Daemon offline". The device port must be reachable from the device: it is published on the LAN, so the Presto needs the address of the machine that runs the container, and the firewall must let 8787 through. curl http://<the machine>:8787/healthz from another machine on the LAN is the quickest check.

A source says it is unavailable. In a container that is expected for the Claude readers and for Codex. The reason is in the account's status in the admin page, and in the daemon's log.