Actual Budget on Kubernetes without Docker Compose, with Kapitan
Actual Budget’s sync server, a personal finance app with its web UI, with its data on a claim, rendered by the kadet-hub kubernetes generator 1.14.0 from the Docker Compose file of Actual 26.11.0.
- Latest version:
1.0.0, published - Source: Docker Compose file
actualbudget/actual@1766241e3f4b3c578e424c4fa15f1eb83c0da17d(packages/sync-server/docker-compose.yml),sha256:bdf050bf6247938005c871b20d2c27647e09843a21631ef5ca430dfc4e5982a6 - Proof level:
deploys, deployed on kind and smoke tested by the kadet-hub/recipes CI renderspassed in the release run of with check 0.18.0, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade F 243 findings, one per advisory and package, in the 1 image of version 1.0.0: CRITICAL 1, HIGH 44, MEDIUM 114, LOW 83, UNKNOWN 1. Scanned 2026-11-07 11:47 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.io/actualbudget/actual-server.
What it deploys
recipes.actual(init.yml)recipes.actual.actual(actual.yml):actual
Required parameters
No parameter has to be set.
Actual takes its server password on the first visit of the web UI, through
/account/bootstrap, which needs no login (DEC-220 in
docs/specs/recipes.md). Until then anyone who
reaches the Service can set it. Set it before the target exposes the
Service, for example through
kubectl port-forward -n actual service/actual 5006 and
http://localhost:5006.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.actual
parameters:
namespace: actual
kapitan:
vars:
target: actual
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.0:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/actual:1.0.0@sha256:198fb46121139a2ac0e5b493d5140250ab8a7a353b10d0a495bae75fc80d3487
output_path: inventory/classes/recipes/actual
The first command fetches the recipe into inventory/classes/recipes/actual, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/actual@sha256:198fb46121139a2ac0e5b493d5140250ab8a7a353b10d0a495bae75fc80d3487 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
Settings
| Setting | Inventory |
|---|---|
| Actual version | components.actual.image, tag and digest |
settings of Actual’s configuration page, such as ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB |
components.actual.env.<variable> |
| exposure | components.actual.service.type, or a route or Ingress of the target to the Service actual on port 5006 |
size of the claim actual-data, 1Gi |
components.actual.volume_claims.actual-data.spec.resources.requests.storage |
| resources | components.actual.resources |
Actual keeps its state in /data on the claim. server-files/account.sqlite
holds the password hash, the sessions and the list of budget files, and
user-files holds the budget files and their sync messages. SQLite allows
one replica.
The Service actual serves plain HTTP on port 5006, the port the compose
file publishes. Actual’s documentation asks for HTTPS for all of its
features, unless the browser reaches it on localhost as above, so expose
it through a route or Ingress that terminates TLS.
Mapping
The service actual_server becomes the component actual. No environment
value, command or config names the service, so nothing is rewritten.
| Compose | Recipe |
|---|---|
service actual_server |
StatefulSet actual, Service actual |
docker.io/actualbudget/actual-server:latest |
docker.io/actualbudget/actual-server:26.11.0@sha256:06cf186569cf58d93e12659ff1a517b65b5b0ebee7050a3991d4bd8a1d398da6, which latest was at migration |
port 5006:5006 |
Service port http on 5006 to the container’s port 5006, ClusterIP |
environment without an entry |
not rendered |
bind mount ./actual-data at /data |
claim template actual-data, ReadWriteOnce, 1Gi |
healthcheck, node scripts/health-check.js |
readiness probe sh -c 'node scripts/health-check.js' every 60 seconds with a timeout of 10 seconds and 3 failures |
restart: unless-stopped |
not rendered |
Every line of the file’s environment is a comment, and the file asks to
remove the key when no option is set. Docker Compose 5.6.1 refuses the file
as it is, with services.actual_server.environment must be a mapping.
Proof
check actual --level deploys with tests/target.yml, on kind for each
Kubernetes minor CI tests, with check 0.16.0. A compose file has no
Kubernetes objects to compare with, so the recipe is proven by deploying it
(RCP-38).
tests/smoke starts a pod of Actual’s image, which carries node. From
there, through the Service actual on port 5006, it
- requires
UPfrom/healthand the version of the image tag from/info - gets 401 from
/sync/list-user-fileswithout a login - sets the password through
/account/bootstrap, and getsalready-bootstrappedfrom a second call andinvalid-passwordfrom a login with another password - logs in, uploads a budget file through
/sync/upload-user-file, and finds it in/sync/list-user-filesand unchanged from/sync/download-user-file - deletes the pod
actual-0and, once the new pod is ready, logs in with the same password and downloads the same file, which needs both on the claim.
The uploaded file is a fixed byte string. The server stores an upload as it is and reads it only when a client downloads it, so the test needs no budget that the web UI built.
Not tested: the web UI in a browser, synchronization of budget changes
through /sync/sync, bank synchronization, OpenID and HTTPS.
Versions
1.0.0, :sha256:198fb46121139a2ac0e5b493d5140250ab8a7a353b10d0a495bae75fc80d3487
The recipe's files are in kadet-hub/recipes. All recipes