Skip to content

Deployment

Use this page after completing Installation. It covers production networking, HTTPS, persistent storage, and hardening.

A typical deployment uses one container, a persistent /data volume, an HTTPS endpoint, and network access for the selected connection mode.

It is a Next.js app. Where the traffic goes depends entirely on the connection mode:

  • Direct mode - the browser talks to your Actual Server itself, running Actual’s engine in a worker. Bench serves the app and nothing else. No budget data passes through it.
  • HTTP API mode - the browser only ever talks to Bench, which forwards to actual-http-api server-side. The browser never sees that URL.

Either way, Bench keeps its own metadata database - SQLite, under /data.

Multi-architecture images are published for linux/amd64 and linux/arm64:

  • xrous/actual-bench:latest - stable, for normal production.
  • xrous/actual-bench:<version> (e.g. :1.2.4) - a pinned, unchanging version.
  • xrous/actual-bench:edge - unstable, rebuilt on every merge; for testing only.

Pin a :<version> in production and upgrades happen when you decide, not when you restart.

services:
actual-bench:
image: xrous/actual-bench:1.2.4 # pin a version in production
container_name: actual-bench
ports:
- "3000:3000"
environment:
ACTUAL_BENCH_DB_PATH: /data/actual-bench.sqlite
# ACTUAL_BENCH_VAULT_KEY: "..." # optional: hold the vault key yourself
volumes:
- actual-bench-data:/data
restart: unless-stopped
volumes:
actual-bench-data:

See Configuration for the full environment-variable reference.

Docker Compose above is the universal method; these run the same image with a platform’s own tooling instead. The single-instance/SQLite model below applies to all of them equally.

The public demo uses a split deployment: Vercel serves the UI and Web Analytics, while its /api/* requests are rewritten to the Hugging Face Space. The Space keeps the shared Actual budgets and Bench metadata together in its stateful container. Merges to main deploy the UI through Vercel’s Git integration; backend, seed, or metadata-schema changes also require updating the Space. Visitors share disposable sample data, which resets when the Space restarts. Never use real financial data in the public demo. The current project-level redirect also catches Vercel preview URLs, so they continue to show the Space until that rule is changed.

See the public demo deployment guide for environment variables, routing, and cutover checks.

Follow Deploy on Fly.io: a step-by-step guide from creating a Fly account to signing in, with the repository’s fly.toml template (one always-on machine, a volume at /data, a health check on /api/health). Open the app and set your password as soon as it starts, since it is on the internet from that moment.

Available once the official PikaPods listing is approved. It will run the same image with one port (3000) and one persistent volume (/data); no environment variables are required.

There are two different reachability requirements here. Mixing them up is the single most common deployment mistake:

  • Browser → service (Direct mode): the user’s browser must reach the Actual Server URL.
  • Container → container (HTTP API mode): the Actual Bench container must reach actual-http-api.

Direct mode runs Actual’s engine in the browser. That has consequences:

  • The browser must be able to reach your Actual Server URL.
  • For scheduled runs on Direct budgets (backups, bank sync, Cross Budget Sync), Bench opens the budget on its own server, so the Bench server must be able to reach the Actual Server URL too.
  • The response has to satisfy CORS, or come through your proxy as same-origin.
  • Actual Bench sets the cross-origin isolation headers it needs on its own assets (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp).
  • Serve Actual Bench over HTTPS, and the Actual Server URL too. Direct mode sends authenticated requests to that URL straight from the browser, so http:// there puts the server password on the wire. Plain HTTP is only acceptable on an internal hop behind a same-origin HTTPS proxy.

If your environment genuinely can’t support Direct mode, there’s nothing to configure ahead of time: Bench reports the failure clearly the moment someone tries it, and they can use HTTP API Server mode instead.

  • The Actual Bench container must reach the actual-http-api base URL.
  • Only Bench needs that path. The browser never uses it.
  • If both run in Docker, attach them to a shared network and use the API’s service name.

fetch failed or 502 Bad Gateway in HTTP mode is container-to-container networking, nine times out of ten.

Put a reverse proxy in front and terminate HTTPS there:

  • Forward the correct host and protocol headers.
  • HTTPS end to end where you can. Direct mode needs a secure, isolated origin to work at all.
  • Serving Actual Server through the same proxy makes it same-origin, which solves Direct mode’s CORS and isolation requirements without opening CORS up.

Open App Health (sidebar footer) and confirm:

  • Writable is Yes.
  • Schema shows a version (migrations run on boot).
  • Persistence reads Persistent when /data is mounted.
  • The Automations section shows the credential vault’s state and where its key comes from, enrolled connections, and each automation’s health.

Actual Bench asks for a password before anything else (see Sign-in). The first visitor to a new install sets it, so open Actual Bench right after deploying it, or set ACTUAL_BENCH_PASSWORD in advance.

  • For an internet-facing install, an extra layer in front (your identity provider, or a VPN such as Tailscale) is still worth having. If that layer already controls access, you can turn Bench’s own sign-in off with ACTUAL_BENCH_AUTH=none.
  • When a proxy is the public entry point, don’t also publish port 3000 on the host. Anyone could reach it directly and bypass the proxy.

Actual Bench refuses state-changing API requests sent from other websites, so a page you visit can’t act on a Bench instance it can reach through your browser, even one that is only on your LAN.

  • Terminate HTTPS at the proxy. Plain HTTP stops at localhost.
  • Enable HSTS at the proxy (for example Strict-Transport-Security: max-age=31536000) once the hostname is HTTPS-only, and use its modern TLS defaults. Actual Bench doesn’t send HSTS itself, because it also runs over plain HTTP on a LAN.
  • Actual Bench sends its own browser security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and a Content-Security-Policy with a fresh nonce on every page, so only Actual Bench’s own scripts run). Don’t strip or replace these headers at the proxy. A proxy that adds its own Content-Security-Policy would block the app’s scripts, because it can’t carry the app’s per-page nonce. If a CDN in front of Actual Bench rewrites pages to add its own scripts (Cloudflare’s Rocket Loader, for example), switch that feature off for the Actual Bench hostname in the CDN’s settings; the policy would block those scripts. Plain reverse proxies (Traefik, Caddy, Nginx) are not affected.
  • Actual Bench’s server refuses to connect to the cloud metadata addresses (such as 169.254.169.254) when a connection or backup destination names one, or redirects to one. LAN addresses, localhost and your container runtime’s host address are fine.
  • When a server Actual Bench connects to redirects it to a different address, or from https to http, Actual Bench stops there rather than send your API key on, and shows the address it was sent to. Use that address in the connection instead. (http:// to https:// on the same address is followed.)
  • If you set ACTUAL_BENCH_VAULT_KEY, keep it out of the image and out of git. Use your platform’s secret manager.
  • Persist /data, back it up, and lock down its permissions. It holds the generated vault key (secrets/vault.key) as well as the database.
  • Pin a specific :<version> and upgrade deliberately (see Upgrading and Backups).
  • Don’t publicly expose backend APIs (actual-http-api, Actual Server) more than necessary.
  • Redact secrets before sharing logs.

The unattended sync scheduler runs in-process and ticks about once a minute; a single instance is sufficient and is the intended model. Do not assume distributed-lock behavior across multiple replicas.

  • Pinned image :<version> chosen.
  • /data volume mounted, writable, and backed up.
  • Password set (first visit or ACTUAL_BENCH_PASSWORD); port 3000 not published beside the proxy.
  • HTTPS terminated at the proxy; host/protocol headers forwarded.
  • Direct mode: browser can reach Actual Server; CORS / cross-origin isolation satisfied.
  • HTTP mode: Actual Bench container can reach actual-http-api.
  • ACTUAL_BENCH_VAULT_KEY stored securely, if you set one; otherwise /data/secrets/vault.key is backed up with the volume.
  • App Health shows Writable = Yes, a valid schema version, and the credential vault Ready.