Skip to content

Docker Compose

The recommended way to run the importer. The repo ships a docker-compose.yml that starts both Actual Budget and the importer as a single stack.

Files in the repo

docker-compose.yml          # Actual Budget + importer
config.json.example         # template — copy to config/config.json

First-time setup

# 1. Start Actual Budget only
docker compose up -d actual-server

# 2. Open http://localhost:5006
#    - Sign in or create your account
#    - Create a budget
#    - Settings → Show Advanced Settings → Sync ID → copy

# 3. Put config.json in ./config/ and edit it (config/config.json):
#      "serverURL": "http://actual-server:5006"   ← internal service name
#      "syncId": "<your Sync ID>"

# 4. Start the importer
docker compose up -d importer

Daily operations

docker compose up -d                            # start everything
docker compose logs -f                          # tail logs
docker compose pull && docker compose up -d     # update both services
docker compose down                             # stop everything

serverURL inside compose vs outside

  • Inside docker-compose: http://actual-server:5006 (internal Docker network DNS).
  • From a browser / standalone docker run: http://localhost:5006.

Customize schedule and timezone

Edit docker-compose.yml:

services:
  importer:
    environment:
      - TZ=Asia/Jerusalem
      - SCHEDULE=0 */8 * * *    # Every 8 hours

Persistence

Named volumes are used by default — data survives container recreation. To use host bind mounts instead, edit the volumes: block in docker-compose.yml:

volumes:
  - ./config:/app/config:ro     # config DIRECTORY (config.json + optional credentials.json) — importer is read-only
  - ./data:/app/data
  - ./cache:/app/cache

:ro is correct for the importer — it never writes its config. If you enable the optional config portal (see docs/configuration/portal.md), the portal service must mount the same ./config directory read-write (./config:/app/config:rw): the portal saves atomically (write a temp file, then rename), which a read-only — or single-file — mount breaks. Always bind-mount the directory, never a single config.json file. The portal must also mount the importer's data directory at the same /app/data path (./data:/app/data here): the two services pass OTP codes, device tokens and the import history through it. Keep it on a local disk or a Docker volume, since it needs hard links, which an SMB/CIFS share does not have.

Memory limits

The importer ships with a 2 GB memory ceiling in docker-compose.yml:

services:
  importer:
    mem_limit: 2g
    memswap_limit: 2g       # no swap growth beyond the ceiling
    deploy:
      resources:
        limits:
          memory: 2g

Both forms are set on purpose — deploy.resources is ignored by Compose v1, mem_limit is ignored by Swarm.

Do not remove them. A stalled bank scrape can strand a browser process; without a ceiling the container keeps allocating host RAM until the machine stops scheduling processes and becomes unreachable. With the ceiling, only the import process is killed and the host is unaffected.

The 2 GB figure is measured, not guessed:

Component Measured
Node baseline ~200 MB
One bank browser (worst case, heavy SPA) ~1 GB
Receipt OCR (tesseract + sharp) ~300 MB
Headroom remainder

Raise it only if you see the container OOM-killed on a bank that legitimately needs more; check with docker inspect israeli-bank-importer --format '{{.State.OOMKilled}}'.

--max-old-space-size is not a substitute. It caps only the JS heap, while most of the importer's footprint is native memory owned by the browser. The cgroup limit is what actually protects the host.

Health

docker compose ps        # both services up?
docker compose top       # process tree per container
docker compose stats     # CPU / mem / IO

For long-term operations, see VM deployment.