kadet-hub logokadet-hub

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.

What it deploys

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

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

The recipe's files are in kadet-hub/recipes. All recipes