DockerSelf-Hosting

Firefly III with Docker: a self-hosted personal finance manager (Linux, Windows, Mac)

Before you start

  • 23 min read

Boxes marked Run in terminal are what you type; plain text explains why. Check what you need first

What you get in one minute#

Firefly III is a free, self-hosted personal finance manager: you record your accounts, income and expenses in your own web app, add budgets and reports, and your financial data stays on your own server.

Firefly III official logo

Logo: the official Firefly III project (github.com/firefly-iii/firefly-iii). The README credits the logo to Cherie Woo.

It suits a person or household that wants to see where the money goes without uploading bank records to a cloud service. The project says it "will never contact external servers until you explicitly tell it to". Bank import is not automatic: the separate Data Importer reads your bank's files or a third-party bank link.

The project is alive: v6.7.9 came out on 11 October 2026. It is a one-person "side gig".

WhatValue (official sources, read November 2026)
Current versionFirefly III v6.7.9 (released October 2026). Data Importer v2.3.5 (September 2026)
Images to pinfireflyiii/core:version-6.7.9 and fireflyiii/data-importer:version-2.3.5 (both tags exist on Docker Hub, amd64 and arm64)
Time neededAbout 30 minutes (our estimate, not measured)
SizeAbout 274 MB compressed for the app image on amd64 (Docker Hub). RAM is not documented
PortsWe publish the app on 127.0.0.1:8080 and the importer on 127.0.0.1:8081
LicenceGNU AGPL v3 (README and LICENSE)
Official docsdocs.firefly-iii.org and the repository

Screenshot of the Firefly III home page with balance, bills, a chart of accounts and a list of recent transactions

Image: the official Firefly III docs (Screenshots page, licence CC BY-SA 4.0). It is an older screenshot with demo data, so a current release looks a little different.

At a glance:

  • What it is: a PHP web app with a MariaDB database, plus a small timer container for recurring jobs.
  • Secrets that matter: APP_KEY, the database password and STATIC_CRON_TOKEN.
  • Cron is not built in: without the timer container, recurring transactions and auto-budgets never run.
  • The Data Importer is separate: its own image, login handshake and settings.
  • Platforms: Linux, Windows and Mac with Docker. Images exist for amd64 and arm64 only.
  • No AI, no cloud: the project's FAQ says it will not add AI features.

How it fits together#

You use one web app. A timer container calls it every night. The optional importer talks to it through its API.

Three-column diagram: in the middle you open Firefly III in a browser, optionally through Caddy on port 443 over HTTPS, and the app stores everything in a MariaDB volume; on the left an optional Data Importer on port 8081 takes a bank file or bank link and sends it to the app through the API; on the right a cron container calls the app every day at 03:00

Drawn for this guide from the official compose files and docs. Dashed parts are optional.

Before you start#

You need Docker with Compose and a few GB of free disk.

Linux: Docker Engine and the Compose plugin, x86-64 or arm64, plus curl. Check:

Run in terminalbash
docker --version
docker compose version

Windows: Docker Desktop with the WSL 2 backend (Linux containers). Check in PowerShell:

Run in terminalpowershell
docker --version
docker compose version

Mac: Docker Desktop. Check in Terminal:

Run in terminalbash
docker --version
docker compose version

Checklist:

  • [ ] Docker and Compose answer the commands above
  • [ ] Ports 8080 and 8081 are free on the machine
  • [ ] You know your time zone name (for example Europe/London); the nightly job runs at 03:00 in it
  • [ ] For the HTTPS section only: a domain with a DNS record for the server, and ports 80 and 443 free

Tested by DIY Lab Hub: not yet. The commands come from the official compose files, .env.example files and docs read in November 2026. We did not run them.

Step 1: Create the folder and the three settings files#

Settings live in .env (app), .db.env (database) and .importer.env (importer) in ~/docker_volumes/firefly-iii. APP_KEY must be exactly 32 characters without special characters; the blocks below use the official generator. Set the passwords now: the docs warn a database password changed after the first start is not applied.

Linux#

Edit the two variables on the first lines, then run the block:

Run in terminalbash
TZ_NAME="Etc/UTC"          # for example Europe/London
OWNER_EMAIL="[email protected]"
mkdir -p ~/docker_volumes/firefly-iii/backups
cd ~/docker_volumes/firefly-iii
gen() { head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32; }
DBPASS=$(gen)
curl -fsSLo .env https://raw.githubusercontent.com/firefly-iii/firefly-iii/v6.7.9/.env.example
curl -fsSLo .importer.env https://raw.githubusercontent.com/firefly-iii/data-importer/v2.3.5/.env.example
sed -i -e "s|^APP_KEY=.*|APP_KEY=$(gen)|" -e "s|^STATIC_CRON_TOKEN=.*|STATIC_CRON_TOKEN=$(gen)|" \
  -e "s|^DB_PASSWORD=.*|DB_PASSWORD=${DBPASS}|" -e "s|^TZ=.*|TZ=${TZ_NAME}|" \
  -e "s|^SITE_OWNER=.*|SITE_OWNER=${OWNER_EMAIL}|" -e "s|^APP_URL=.*|APP_URL=http://localhost:8080|" .env
sed -i -e "s|^FIREFLY_III_URL=.*|FIREFLY_III_URL=http://app:8080|" -e "s|^VANITY_URL=.*|VANITY_URL=http://localhost:8080|" \
  -e "s|^TZ=.*|TZ=${TZ_NAME}|" -e "s|^LOG_LEVEL=.*|LOG_LEVEL=notice|" .importer.env
printf 'MYSQL_RANDOM_ROOT_PASSWORD=yes\nMYSQL_USER=firefly\nMYSQL_PASSWORD=%s\nMYSQL_DATABASE=firefly\n' "$DBPASS" > .db.env
chmod 600 .env .db.env .importer.env

Windows (PowerShell)#

Edit the two variables on the first lines, then run the block:

Codepowershell
$tz = "Etc/UTC"            # for example Europe/London
$owner = "[email protected]"
New-Item -ItemType Directory -Force "$HOME\docker_volumes\firefly-iii\backups" | Out-Null
Set-Location "$HOME\docker_volumes\firefly-iii"
function New-Secret { $b = New-Object byte[] 16; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); -join ($b | ForEach-Object { $_.ToString('x2') }) }
$dbpass = New-Secret
$e = (Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/firefly-iii/firefly-iii/v6.7.9/.env.example).Content
$e = $e -replace '(?m)^APP_KEY=.*', "APP_KEY=$(New-Secret)" -replace '(?m)^STATIC_CRON_TOKEN=.*', "STATIC_CRON_TOKEN=$(New-Secret)"
$e = $e -replace '(?m)^DB_PASSWORD=.*', "DB_PASSWORD=$dbpass" -replace '(?m)^TZ=.*', "TZ=$tz"
$e = $e -replace '(?m)^SITE_OWNER=.*', "SITE_OWNER=$owner" -replace '(?m)^APP_URL=.*', 'APP_URL=http://localhost:8080'
Set-Content -Path .env -Value $e -Encoding ascii
$i = (Invoke-WebRequest -UseBasicParsing https://raw.githubusercontent.com/firefly-iii/data-importer/v2.3.5/.env.example).Content
$i = $i -replace '(?m)^FIREFLY_III_URL=.*', 'FIREFLY_III_URL=http://app:8080' -replace '(?m)^VANITY_URL=.*', 'VANITY_URL=http://localhost:8080'
$i = $i -replace '(?m)^TZ=.*', "TZ=$tz" -replace '(?m)^LOG_LEVEL=.*', 'LOG_LEVEL=notice'
Set-Content -Path .importer.env -Value $i -Encoding ascii
"MYSQL_RANDOM_ROOT_PASSWORD=yes`nMYSQL_USER=firefly`nMYSQL_PASSWORD=$dbpass`nMYSQL_DATABASE=firefly" | Set-Content -Path .db.env -Encoding ascii

Mac (Terminal)#

Same as Linux, except sed -i needs an empty suffix on a Mac. Replace each sed -i with sed -i '', and keep everything else:

Run in terminalbash
TZ_NAME="Etc/UTC"          # for example Europe/London
OWNER_EMAIL="[email protected]"
mkdir -p ~/docker_volumes/firefly-iii/backups
cd ~/docker_volumes/firefly-iii
gen() { head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32; }
DBPASS=$(gen)
curl -fsSLo .env https://raw.githubusercontent.com/firefly-iii/firefly-iii/v6.7.9/.env.example
curl -fsSLo .importer.env https://raw.githubusercontent.com/firefly-iii/data-importer/v2.3.5/.env.example
sed -i '' -e "s|^APP_KEY=.*|APP_KEY=$(gen)|" -e "s|^STATIC_CRON_TOKEN=.*|STATIC_CRON_TOKEN=$(gen)|" \
  -e "s|^DB_PASSWORD=.*|DB_PASSWORD=${DBPASS}|" -e "s|^TZ=.*|TZ=${TZ_NAME}|" \
  -e "s|^SITE_OWNER=.*|SITE_OWNER=${OWNER_EMAIL}|" -e "s|^APP_URL=.*|APP_URL=http://localhost:8080|" .env
sed -i '' -e "s|^FIREFLY_III_URL=.*|FIREFLY_III_URL=http://app:8080|" -e "s|^VANITY_URL=.*|VANITY_URL=http://localhost:8080|" \
  -e "s|^TZ=.*|TZ=${TZ_NAME}|" -e "s|^LOG_LEVEL=.*|LOG_LEVEL=notice|" .importer.env
printf 'MYSQL_RANDOM_ROOT_PASSWORD=yes\nMYSQL_USER=firefly\nMYSQL_PASSWORD=%s\nMYSQL_DATABASE=firefly\n' "$DBPASS" > .db.env
chmod 600 .env .db.env .importer.env

Check it worked. The key lines exist and the key is 32 characters (this hides the secrets):

Run in terminalbash
grep -E '^(APP_KEY|STATIC_CRON_TOKEN|DB_PASSWORD|APP_URL|TZ|SITE_OWNER)=' .env | sed -E 's/^(APP_KEY|STATIC_CRON_TOKEN|DB_PASSWORD)=.*/\1=<hidden>/'
awk -F= '$1=="APP_KEY"{print length($2)}' .env

The second command must print 32. On Windows use (Get-Content .env | Where-Object { $_ -like 'APP_KEY=*' }).Length - 8, which must also be 32.

Tip: Copy .env into a password manager now. The .env file calls APP_KEY the encryption key and the backup docs name it as something to keep. Our Vaultwarden guide shows a self-hosted one.

Step 2: Write the Compose file#

This is the official Compose file with pinned tags, restart: unless-stopped, the app on 127.0.0.1:8080 only, explicit volume and network names, and the importer behind a profile so it stays off until Step 6. The official mariadb:lts pointed to MariaDB 12.3.3 in November 2026; we pin that number.

Linux#

Run in terminalbash
cd ~/docker_volumes/firefly-iii
cat > docker-compose.yml << 'EOF'
services:
  app:
    image: fireflyiii/core:version-6.7.9
    container_name: firefly_iii_core
    hostname: app
    stop_signal: SIGINT
    restart: unless-stopped
    env_file: .env
    volumes:
      - firefly_iii_upload:/var/www/html/storage/upload
    ports:
      - "127.0.0.1:8080:8080"
    networks:
      - firefly_iii
    depends_on:
      - db

  db:
    image: mariadb:12.3.3
    container_name: firefly_iii_db
    hostname: db
    restart: unless-stopped
    env_file: .db.env
    volumes:
      - firefly_iii_db:/var/lib/mysql
    networks:
      - firefly_iii

  cron:
    image: alpine:3.24.3
    container_name: firefly_iii_cron
    restart: unless-stopped
    env_file: .env
    init: true
    command: ["sh", "-c", "apk add tzdata && \
      (ln -s /usr/share/zoneinfo/$$TZ /etc/localtime || true) && \
      echo \"0 3 * * * wget -qO- http://app:8080/api/v1/cron/$$STATIC_CRON_TOKEN;echo\" \
      | crontab - && \
      exec crond -f -L /dev/stdout"]
    networks:
      - firefly_iii
    depends_on:
      - app

  importer:
    image: fireflyiii/data-importer:version-2.3.5
    container_name: firefly_iii_importer
    hostname: importer
    restart: unless-stopped
    profiles: ["importer"]
    env_file: .importer.env
    ports:
      - "127.0.0.1:8081:8080"
    networks:
      - firefly_iii
    depends_on:
      - app

volumes:
  firefly_iii_upload:
    name: firefly_iii_upload
  firefly_iii_db:
    name: firefly_iii_db

networks:
  firefly_iii:
    name: firefly_iii
    driver: bridge
EOF

Windows (PowerShell)#

The same file through a here-string:

Codepowershell
Set-Location "$HOME\docker_volumes\firefly-iii"
@'
services:
  app:
    image: fireflyiii/core:version-6.7.9
    container_name: firefly_iii_core
    hostname: app
    stop_signal: SIGINT
    restart: unless-stopped
    env_file: .env
    volumes:
      - firefly_iii_upload:/var/www/html/storage/upload
    ports:
      - "127.0.0.1:8080:8080"
    networks:
      - firefly_iii
    depends_on:
      - db

  db:
    image: mariadb:12.3.3
    container_name: firefly_iii_db
    hostname: db
    restart: unless-stopped
    env_file: .db.env
    volumes:
      - firefly_iii_db:/var/lib/mysql
    networks:
      - firefly_iii

  cron:
    image: alpine:3.24.3
    container_name: firefly_iii_cron
    restart: unless-stopped
    env_file: .env
    init: true
    command: ["sh", "-c", "apk add tzdata && \
      (ln -s /usr/share/zoneinfo/$$TZ /etc/localtime || true) && \
      echo \"0 3 * * * wget -qO- http://app:8080/api/v1/cron/$$STATIC_CRON_TOKEN;echo\" \
      | crontab - && \
      exec crond -f -L /dev/stdout"]
    networks:
      - firefly_iii
    depends_on:
      - app

  importer:
    image: fireflyiii/data-importer:version-2.3.5
    container_name: firefly_iii_importer
    hostname: importer
    restart: unless-stopped
    profiles: ["importer"]
    env_file: .importer.env
    ports:
      - "127.0.0.1:8081:8080"
    networks:
      - firefly_iii
    depends_on:
      - app

volumes:
  firefly_iii_upload:
    name: firefly_iii_upload
  firefly_iii_db:
    name: firefly_iii_db

networks:
  firefly_iii:
    name: firefly_iii
    driver: bridge
'@ | Set-Content -Path docker-compose.yml -Encoding ascii

Mac (Terminal)#

Same file and command as Linux.

Named Docker volumes hold the database and uploads, as in the official file: the app runs as the unprivileged www-data user and may not write to a root-owned host folder. Config, secrets and backups live in ~/docker_volumes/firefly-iii.

Check it worked. Compose reads the file without errors:

Run in terminalbash
docker compose config --quiet && echo OK

On Windows run the same command. It prints OK when the file is valid.

Tip: The $$ in the cron command is correct. Compose turns $$ into a single $ so the container's own shell reads TZ and STATIC_CRON_TOKEN. The official docs say the same.

Step 3: Start Firefly III#

Linux#

Run in terminalbash
cd ~/docker_volumes/firefly-iii
docker compose up -d
docker compose ps
docker compose logs -f app

Windows (PowerShell)#

Codepowershell
Set-Location "$HOME\docker_volumes\firefly-iii"
docker compose up -d
docker compose ps
docker compose logs -f app

Mac (Terminal)#

Same commands as Linux.

The first start downloads images and sets up the database, so it takes a few minutes. Press Ctrl+C to leave the log. The docs say Firefly III "will thank you for installing it" when it is done.

Check it worked. docker compose ps lists firefly_iii_core, firefly_iii_db and firefly_iii_cron. The app image has its own health check against /healthcheck and allows a 5-minute start period, so unhealthy in the first minutes can be normal. Then:

Run in terminalbash
curl -fsS -o /dev/null http://localhost:8080/healthcheck && echo OK

On Windows use curl.exe -fsS -o NUL http://localhost:8080/healthcheck; if ($LASTEXITCODE -eq 0) { "OK" }.

Tip: Do not restart the stack over and over while it starts. Read the app and db logs first; a database password mismatch is the usual cause of start errors.

Step 4: Create the first user and turn on 2FA#

Open http://localhost:8080. The first visit shows a registration form. The first user becomes the owner, and the docs say registration is blocked after that. If Docker runs on another machine, an SSH tunnel brings the ports to your computer (the idea from our wg-easy guide).

Linux#

On the same machine, open http://localhost:8080. From another computer, keep this tunnel open:

Run in terminalbash
ssh -N -L 8080:127.0.0.1:8080 -L 8081:127.0.0.1:8081 <your-user>@<your-server-address>

Windows (PowerShell)#

Same machine: open http://localhost:8080. For a remote server run the same ssh command in PowerShell (the OpenSSH client is a Windows optional feature):

Run in terminalpowershell
ssh -N -L 8080:127.0.0.1:8080 -L 8081:127.0.0.1:8081 <your-user>@<your-server-address>

Mac (Terminal)#

Same command as Linux:

Run in terminalbash
ssh -N -L 8080:127.0.0.1:8080 -L 8081:127.0.0.1:8081 <your-user>@<your-server-address>
  1. Register with your email and a long password from a password manager.
  2. Finish the wizard: currency, language and bank account names. The docs advise entering yesterday's balance, so you can create transactions from today.
  3. Open your profile and enable two-factor authentication. Save the eight backup codes.

Check it worked. You see the dashboard, and Accounts > Asset accounts lists your accounts.

Tip: Register before anyone else can reach the page. Until the first user exists, whoever opens the URL first can create the owner account.

Step 5: Check that the daily cron job works#

Recurring transactions, auto-budgets, bill warnings and exchange-rate updates only run when the cron job runs, and the image has no cron of its own. The cron container calls the app daily at 03:00 in your TZ. Run it once now instead of waiting:

Linux#

Run in terminalbash
cd ~/docker_volumes/firefly-iii
docker compose exec cron sh -c 'wget -qO- http://app:8080/api/v1/cron/$STATIC_CRON_TOKEN; echo'

Windows (PowerShell)#

Codepowershell
Set-Location "$HOME\docker_volumes\firefly-iii"
docker compose exec cron sh -c 'wget -qO- http://app:8080/api/v1/cron/$STATIC_CRON_TOKEN; echo'

Mac (Terminal)#

Same command as Linux.

Check it worked. The answer is JSON with "job_fired":true and "job_succeeded":true for recurring_transactions, auto_budgets, exchange_rates and bill_notifications (the docs' example, not our output). The /debug page lists "Last cron job(s)" with the time of the run.

Tip: A login page or "Unauthenticated" means the token differs from the app's. Both read STATIC_CRON_TOKEN from .env, exactly 32 characters; recreate with docker compose up -d.

Step 6: Add the Data Importer (optional)#

The importer is a separate tool, not a plugin. It reads CSV and CAMT.053 files and some third-party bank services, and logs in to Firefly III with OAuth. Enable its profile with one line in .env, which Compose reads:

Linux#

Run in terminalbash
cd ~/docker_volumes/firefly-iii
echo 'COMPOSE_PROFILES=importer' >> .env
docker compose up -d
docker compose ps

Windows (PowerShell)#

Codepowershell
Set-Location "$HOME\docker_volumes\firefly-iii"
Add-Content -Path .env -Value 'COMPOSE_PROFILES=importer' -Encoding ascii
docker compose up -d
docker compose ps

Mac (Terminal)#

Same commands as Linux.

Then in Firefly III:

  1. Open Options > Profile > OAuth and choose "Create New Client".
  2. Name it Data Importer. Set the redirect URL to http://localhost:8081/callback. The docs say you must always end it with /callback.
  3. Uncheck "Confidential". The importer cannot keep a secret.
  4. Note the Client ID, a small number such as 3.
  5. Open http://localhost:8081, enter the Client ID, authenticate, and give permission.

Check it worked. After you grant permission, the importer's start page opens with its import options.

Tip: The importer reaches Firefly III at http://app:8080 (FIREFLY_III_URL); your browser uses http://localhost:8080 (VANITY_URL). localhost in FIREFLY_III_URL would point at the importer itself.

Put it behind HTTPS (with or without Traefik)#

Use HTTPS if Firefly III is reached over the internet or you want a padlock at home. Both routes need a domain with a DNS record for the server and free ports 80 and 443. Local-only users can skip this section.

Both routes do the same three things: the app drops its host port and joins a shared proxy network, .env gets the HTTPS address and TRUSTED_PROXIES=* (the docs say a missing value causes CSP errors and http links; * is fine here because only the proxy reaches the unpublished app), and the importer's VANITY_URL becomes the HTTPS address. Keep the importer on 127.0.0.1.

Create the network once if it is missing:

Run in terminalbash
docker network create proxy

Update the settings (Linux; on Mac use sed -i ''):

Run in terminalbash
cd ~/docker_volumes/firefly-iii
sed -i -e 's|^APP_URL=.*|APP_URL=https://firefly.<your-domain>|' -e 's|^TRUSTED_PROXIES=.*|TRUSTED_PROXIES=*|' .env
sed -i -e 's|^VANITY_URL=.*|VANITY_URL=https://firefly.<your-domain>|' -e 's|^TRUSTED_PROXIES=.*|TRUSTED_PROXIES=*|' .importer.env

On Windows use (Get-Content .env) -replace '^APP_URL=.*','APP_URL=https://firefly.<your-domain>' -replace '^TRUSTED_PROXIES=.*','TRUSTED_PROXIES=*' | Set-Content .env -Encoding ascii, and the same pattern for .importer.env with VANITY_URL.

With Traefik#

On a server that already runs Traefik v3 with the Docker provider, add labels and remove the host port.

This assumes an entrypoint called https; use your own name. In the app service delete the ports: lines, replace networks: and add labels::

File contentyaml
  app:
    # ...image, env_file, volumes and depends_on as in Step 2, without the ports: lines
    networks:
      - firefly_iii
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.firefly.rule=Host(`firefly.<your-domain>`)"
      - "traefik.http.routers.firefly.entrypoints=https"
      - "traefik.http.routers.firefly.tls=true"
      - "traefik.http.services.firefly.loadbalancer.server.port=8080"

networks:
  firefly_iii:
    name: firefly_iii
    driver: bridge
  proxy:
    external: true

Run docker compose up -d in ~/docker_volumes/firefly-iii.

Check it worked. curl -I https://firefly.<your-domain>/ returns an HTTP status line over HTTPS (a redirect to the login page is normal), and the browser shows a valid certificate.

Without Traefik: Caddy#

Choose this if you have no Traefik. Caddy gets and renews its own certificates.

Caddy's docs describe automatic Let's Encrypt certificates for a public domain, and tls internal for a local certificate at home. Make the same app changes as above but leave out the labels. If you already run Caddy, see our Caddy guide and only add the site block.

Create the Caddy folders:

Run in terminalbash
mkdir -p ~/docker_volumes/caddy/{conf,data,config}
cd ~/docker_volumes/caddy

Save this as ~/docker_volumes/caddy/docker-compose.yml. It mounts the conf folder, not a single file, as Caddy's Docker docs advise. caddy:2.11.8 is the newest image tag on Docker Hub in November 2026 (the release page lists 2.11.9, but its image tag was not published yet):

File contentyaml
services:
  caddy:
    image: caddy:2.11.8
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80/tcp"
      - "443:443/tcp"
      - "443:443/udp"
    volumes:
      - ./conf:/etc/caddy
      - ./data:/data
      - ./config:/config
    networks:
      - proxy

networks:
  proxy:
    external: true

Save this as ~/docker_volumes/caddy/conf/Caddyfile for a public domain:

File contenttext
firefly.example.com {
    reverse_proxy firefly_iii_core:8080
}

For a home-only name, use tls internal:

File contenttext
firefly.home.arpa {
    tls internal
    reverse_proxy firefly_iii_core:8080
}

Start both stacks:

Run in terminalbash
cd ~/docker_volumes/caddy && docker compose up -d
cd ~/docker_volumes/firefly-iii && docker compose up -d

For a home-only certificate, Caddy's docs say you install its root certificate on each device yourself. Copy it out on the Caddy machine:

Run in terminalbash
docker exec caddy find /data -name root.crt
docker cp caddy:<path-from-find> ./caddy-root.crt

Import caddy-root.crt as a trusted root (these OS steps are not from the Firefly III or Caddy docs): Windows, "Trusted Root Certification Authorities"; macOS, Keychain Access with "Always Trust"; Linux, your distribution's CA store.

Windows and Mac (Docker Desktop): the YAML and Caddyfile are identical. Create the three folders, save the files with a text editor, and run the same docker compose commands.

Check it worked. curl -I https://firefly.example.com/ returns an HTTP status line over HTTPS. For a home-only name, the padlock appears only after the root is trusted on that device.

Tip: Let's Encrypt needs the DNS record to point at the server and ports 80 and 443 to reach it. A certificate error usually means the Caddyfile name differs from the DNS record.

Security and testing#

Your finances are sensitive, so the defaults matter.

ControlHow
RegistrationThe first visitor becomes the owner. Register at once
Password and 2FALong unique password; enable 2FA and keep the backup codes
PortsPublished on 127.0.0.1 only. Never forward 8080 or 8081 on your router
Secrets.env, .db.env and .importer.env are chmod 600. .env supports APP_KEY_FILE; Docker secrets can need extra work because the container is not root (FAQ)
Cron tokenThe cron call puts the token in the URL, so it shows in web server logs (issue 13124; the maintainer called it "not that big of a secret"). Keep logs private
Audit logOff by default; it can hold financial details if you enable it
CookiesAfter HTTPS works, COOKIE_SECURE=true (setting exists in .env.example; our suggestion)
Remote user loginAUTHENTICATION_GUARD=remote_user_guard trusts a header with no checks. Use it only behind a login proxy such as our authentik guide. LDAP is no longer supported

Tests after each change:

Run in terminalbash
cd ~/docker_volumes/firefly-iii
docker compose ps
docker compose logs --tail=100 app
docker compose logs --tail=20 cron

Windows and Mac run the same commands; for HTTPS use curl -I https://firefly.<your-domain>/ (curl.exe on Windows).

Bank data and privacy#

The README says Firefly III itself never contacts outside servers until you tell it to. What can leave your machine:

WhatWhen it happens
Exchange ratesOff by default. If you tick "Download exchange rates from the web" at /settings/configuration, they come from a Firefly III Azure bucket (address in config/cer.php)
EmailOnly if you set MAIL_*; the default mailer writes to the log
Cron containerRuns apk add tzdata at every start, so it contacts Alpine's package mirror
Bank linksOnly if you set up a provider in the importer: your bank login goes to the provider
Enable Banking IPWith ENABLE_BANKING_IMPORT_IP=autodetect the importer asks an outside service (icanhazip.com)
LogsThe importer's example sets LOG_LEVEL=debug; we lowered it to notice

Importer providers, per the docs (November 2026):

ProviderStatus
CSV or CAMT.053 fileNo third party. PDF will never be supported
Enable Banking"Free restricted mode" for your own accounts. One docs page still says "Not yet supported" while the importer's example file has its settings, so test on a small import
SimpleFINSupported through a token from the service
Lunch FlowPaid provider
GoCardlessPaid, and "shifting away" from this product
Salt EdgeFree tier ended 31 October 2025; support will be removed

Tip: Start with a CSV or CAMT.053 export from your bank. It needs no third party and no bank login, and it shows you how the importer maps columns before you trust a bank link.

When you report a bug, never upload your CSV or CAMT file to GitHub; the docs ask you to fake names, IBANs and amounts first.

Common mistakes#

These come from the docs' warnings, the issue tracker and common AI assistant answers.

MistakeWhy it happensDo this instead
Changing the database password after the first startIt is stored in the database volume at first start. Issue 11308 (closed) reports Access denied for user 'firefly'Set the same password in .env and .db.env before the first start. A started stack with no data worth keeping: remove containers and volumes, as the docs say
Switching to PostgreSQL but keeping the MariaDB mount pathIssue 9176 reports lost data after down and upMount /var/lib/postgresql for PostgreSQL 18 (/var/lib/postgresql/data for older), as the docs now say
An APP_KEY that is not 32 characters, or a file pathIssue 12998 (October 2026) shows Unsupported cipher with APP_KEY=/run/secrets/app_keyUse the 32-character generator, or APP_KEY_FILE for a secret file
No TRUSTED_PROXIES behind a reverse proxyThe app builds http links. Issue 9189 shows a page with no stylesSet TRUSTED_PROXIES=* and APP_URL to the HTTPS address
Running only the app containerThe image has no cron, so recurring transactions never runKeep the cron container
STATIC_CRON_TOKEN of the wrong lengthThe compose comments say exactly 32 charactersGenerate 32 characters, as in Step 1
Using Export as a backupThe docs: "not a backup mechanism"Back up the volumes and .env
localhost in the importer's FIREFLY_III_URLInside the container it is the importer itselfUse http://app:8080, and VANITY_URL for your browser address
The command line token or APP_KEY as the importer tokenThe importer's example file says both are wrongUse a Client ID ("Confidential" unchecked) or a Personal Access Token
AI assistants: a wrong image name or a built-in cronAnswers mix names (fireflyiii/core is right) and claim the container schedules jobsCopy image names and the cron container from the official compose file
AI assistants: LDAP, or the importer as a pluginOld posts mention LDAP; the project says it is no longer supported and the importer is a separate toolUse the local login or remote_user_guard, and run the importer as its own container

Tip: Set passwords and the APP_KEY in .env before the first docker compose up. Fixing them afterwards means wiping volumes or restoring a backup.

Tip: After any .env change run docker compose up -d, not restart. Only up -d recreates the container with the new values.

Tip: Before pasting a setup from a chat or blog, compare image name, port and cron job with the official compose file at the release tag you pin.

Troubleshooting#

ProblemSolution
unhealthy right after startThe image allows a 5-minute start period. Read docker compose logs app
SQLSTATE[HY000] [1045] Access denied for user 'firefly'Password mismatch between .env and .db.env, or changed after first start
Unsupported cipher (AES-256-CBC) or incorrect key lengthAPP_KEY is not exactly 32 characters, or is a file path
No application encryption key has been specifiedAPP_KEY is empty or was not read (issue 9872). Check the variable exists; do not paste its value anywhere
No styles, mixed content, CSP errorsSet TRUSTED_PROXIES=* and the HTTPS APP_URL, then docker compose up -d
431 or 502 behind a proxyThe proxy's header or buffer size is too small (docs: 8192 or more)
Target class [hash] does not exist after an upgradeDocs: fresh .env, writable storage not owned by root, rm bootstrap/cache/*.php
Allowed memory size of ... bytes exhaustedAdd PHP_MEMORY_LIMIT=512M or more to .env
permission denied on the cache folderdocker compose exec app php artisan cache:clear, or open /flush
Recurring transactions missingRun the Step 5 command; check /debug for "Last cron job(s)"
Importer shows a 500 errorFIREFLY_III_URL must be http://app:8080
Importer returns with odd errorsThe OAuth redirect URL must end in /callback; open /flush

Run these first: docker compose ps, docker compose logs --tail=200 app, docker compose logs --tail=100 db, docker inspect firefly_iii_core.

Known bugs (open, November 2026)#

  • Issue 13057: on v6.7.9, creating a recurring transaction shows raw JSON. The maintainer could not reproduce it.
  • Issues 13122 and 13115: Enable Banking imports can error when nothing comes back, or drop rows when two accounts at one bank share an entry reference.

Updates and backups#

Update. The docs warn an upgrade is sometimes destructive. Back up first, read the release notes, change both image tags, then:

Run in terminalbash
cd ~/docker_volumes/firefly-iii
docker compose stop
docker compose pull
docker compose up -d --remove-orphans
docker compose logs --tail=100 app

This is the docs' sequence; Windows and Mac use the same commands. v6.7.9 fixed the self-hosted "Target class [hash]" error, and we found no widespread breakage report for it. Do not change the MariaDB major version in the same step: the docs warn a re-downloaded compose file may carry a database version that does not match your volume.

Back up. There is no built-in backup. Save the database, the upload volume and the settings files (with the APP_KEY). Check the volumes exist first; the docs warn Docker creates an empty one if the name is wrong.

Linux#

Run in terminalbash
cd ~/docker_volumes/firefly-iii
docker volume inspect firefly_iii_db firefly_iii_upload > /dev/null
d=$(date +%F)
docker compose exec -T db sh -c 'mariadb-dump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" --single-transaction "$MYSQL_DATABASE"' > backups/firefly-db-$d.sql
docker run --rm -v firefly_iii_upload:/data:ro -v "$PWD/backups:/backup" alpine:3.24.3 tar -czf /backup/firefly-upload-$d.tar.gz -C /data .
tar -czf backups/firefly-config-$d.tar.gz .env .db.env .importer.env docker-compose.yml
chmod 600 backups/firefly-*

For a nightly run at 03:30, put these lines in a script and add a crontab entry. Copy the archives to another machine.

Windows (PowerShell)#

Codepowershell
Set-Location "$HOME\docker_volumes\firefly-iii"
docker volume inspect firefly_iii_db firefly_iii_upload | Out-Null
$d = Get-Date -Format yyyy-MM-dd
docker compose exec -T db sh -c 'mariadb-dump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" --single-transaction "$MYSQL_DATABASE" > /tmp/firefly.sql'
docker compose cp db:/tmp/firefly.sql "backups\firefly-db-$d.sql"
docker run --rm -v firefly_iii_upload:/data:ro -v "${PWD}\backups:/backup" alpine:3.24.3 tar -czf /backup/firefly-upload-$d.tar.gz -C /data .
tar -czf "backups\firefly-config-$d.tar.gz" .env .db.env .importer.env docker-compose.yml

Schedule a saved copy with Task Scheduler. The SQL goes through a file in the container because PowerShell's > can change a dump's encoding.

Mac (Terminal)#

Same commands as Linux, with cron or launchd for the schedule.

Restore test. The docs say to restore first, before you trust a backup. Load the SQL into a throwaway MariaDB container, never over the live one:

Run in terminalbash
docker run -d --name ff-restore-test -e MARIADB_RANDOM_ROOT_PASSWORD=1 -e MARIADB_DATABASE=firefly -e MARIADB_USER=firefly -e MARIADB_PASSWORD=restoretest mariadb:12.3.3
sleep 25
docker cp backups/firefly-db-<date>.sql ff-restore-test:/tmp/restore.sql
docker exec ff-restore-test sh -c 'mariadb -ufirefly -prestoretest firefly < /tmp/restore.sql'
docker exec ff-restore-test mariadb -ufirefly -prestoretest firefly -e 'SHOW TABLES;'
docker rm -f ff-restore-test

On Windows use Start-Sleep 25 and the same docker lines. The tables should include transactions and accounts.

Performance and resources#

The docs publish no memory or CPU figures, so this guide gives none. The compressed images are about 274 MB (app) and 292 MB (importer) on amd64.

  • Measure: docker stats shows CPU and memory per container.
  • PHP memory: the image sets PHP_MEMORY_LIMIT=512M; raise it only if you see "Allowed memory size exhausted".

FAQ#

Is Firefly III free, and still maintained? Free under AGPL v3 and active: v6.7.9 appeared in October 2026. One developer runs it.

Does it connect to my bank automatically? No. The Data Importer imports files or uses a third-party bank service.

Can two people share one Firefly III? Each user has a separate administration, and the docs say sharing one is not possible yet.

Can it run on a Raspberry Pi? On a 64-bit system, yes (arm64). arm/v7 and arm/v6 are no longer supported.

Can I use PostgreSQL or SQLite instead? Yes, but switching later is not supported natively.

Sources#

Checked against the official docs on 2026-11-05.