Contents

Firefly III: Self-Hosted Finance for the Stubborn

Double-entry bookkeeping for your own money, whether you asked for it or not

Contents

Four days ago I wrote about Actual Budget, which is a friendly tool that asks you to assign your money to envelopes and otherwise leaves you alone. Firefly III is the other kind of software entirely. It is a full double-entry bookkeeping system, written in PHP by James Cole and maintained with obsessive consistency since 2016, and it will make you declare a source account and a destination account for every krone that has ever moved, including the twenty you found in a coat.

It does not meet you halfway. There is no “just chuck it in miscellaneous” affordance, because in double-entry that money has to come from somewhere and go to somewhere and the books must balance. The first week is genuinely irritating. I abandoned it once, in 2021, and came back eighteen months later because the friendly tools kept failing to answer the question I actually had.

The question was: where did fourteen thousand kroner go last year? The friendly tools answer “which category” with a pie chart. I wanted where. Which account, on which date, to which counterparty, and what did I tell myself about it at the time. Firefly III answers that in about four seconds, because it was built by someone who thought that question was the whole point.

Double-entry, and why the friction is the feature

Advertisement

In Firefly III every transaction has a type and two accounts.

  • Withdrawal: money leaves an asset account (your current account) and arrives at an expense account (Rema 1000, the landlord, the pub).
  • Deposit: money leaves a revenue account (your employer) and arrives at an asset account.
  • Transfer: money moves between two of your own asset accounts, and — critically — this is not income and it is not spending. It nets to zero.

That third one is where most personal finance apps quietly lie to you. Move 5,000kr from current to savings in a naïve app and you can end up with 5,000 of “spending” and, next month, 5,000 of “income” when you move it back. Your reports are then fiction. Firefly III cannot express that error, because a transfer has a type and the type says what it is.

The cost of this rigour is that you must model your accounts before you can enter anything. Asset accounts for each real account, including cash in your pocket if you want cash tracked. Liabilities for the mortgage and the credit card, with opening balances and interest. Revenue accounts for each income source. Expense accounts get created on the fly as you name payees.

This takes an evening. It is a real evening, and it is the reason most people bounce off. What you get for it is that every number the system produces afterwards is true, in the narrow accounting sense that the books balance and nothing has been double-counted. After three years of data, that property compounds into something the friendly tools cannot reach.

If that trade sounds like a bad one, run Actual Budget instead. I mean that sincerely. The two tools answer different questions and there is no shame in wanting the easier one; I run both, for different reasons, which I will come back to.

The stack

Firefly III is Laravel, so it wants PHP, a database, and a cron. The official compose is three services.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
services:
  firefly:
    image: fireflyiii/core:version-6.1.21
    container_name: firefly
    depends_on:
      firefly-db:
        condition: service_healthy
    environment:
      - APP_ENV=production
      - APP_KEY_FILE=/run/secrets/firefly_app_key
      - APP_URL=https://money.mylab.local
      - TZ=Europe/Copenhagen
      - TRUSTED_PROXIES=**
      - [email protected]
      - DEFAULT_LANGUAGE=en_GB
      - DB_CONNECTION=pgsql
      - DB_HOST=firefly-db
      - DB_PORT=5432
      - DB_DATABASE=firefly
      - DB_USERNAME=firefly
      - DB_PASSWORD_FILE=/run/secrets/firefly_db
      - STATIC_CRON_TOKEN_FILE=/run/secrets/firefly_cron
      - MAIL_MAILER=log
    volumes:
      - /srv/appdata/firefly/upload:/var/www/html/storage/upload
    secrets:
      - firefly_app_key
      - firefly_db
      - firefly_cron
    ports:
      - "127.0.0.1:8080:8080"
    restart: unless-stopped

  firefly-db:
    image: postgres:16-alpine
    container_name: firefly-db
    environment:
      - POSTGRES_USER=firefly
      - POSTGRES_DB=firefly
      - POSTGRES_PASSWORD_FILE=/run/secrets/firefly_db
    volumes:
      - /srv/appdata/firefly/pgdata:/var/lib/postgresql/data
    secrets:
      - firefly_db
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U firefly"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  firefly-cron:
    image: alpine:3.21
    container_name: firefly-cron
    restart: unless-stopped
    command: >
      sh -c "
        TOKEN=$$(cat /run/secrets/firefly_cron);
        echo \"0 3 * * * wget -qO- http://firefly:8080/api/v1/cron/$$TOKEN\" > /etc/crontabs/root;
        crond -f -L /dev/stdout
      "
    secrets:
      - firefly_cron

secrets:
  firefly_app_key:
    file: /srv/secrets/firefly_app_key.txt
  firefly_db:
    file: /srv/secrets/firefly_db.txt
  firefly_cron:
    file: /srv/secrets/firefly_cron.txt

Generate the two secrets properly. Both have length requirements and both fail in confusing ways if you improvise:

1
2
3
4
5
$ head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32 > /srv/secrets/firefly_app_key.txt
$ head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32 > /srv/secrets/firefly_cron.txt
$ chmod 600 /srv/secrets/firefly_*.txt
$ wc -c /srv/secrets/firefly_app_key.txt
32 /srv/secrets/firefly_app_key.txt

APP_KEY must be exactly 32 characters. Laravel uses it to encrypt session data and anything Firefly stores encrypted. Change it later and previously encrypted values become unreadable, which is a genuinely bad afternoon. Set it once, back it up alongside the database, and treat it as part of the data — the sort of thing that belongs in the workflow from Secrets Management with SOPS and age rather than in a compose file you paste into a gist.

TRUSTED_PROXIES=** is required behind a reverse proxy. Without it Laravel generates http:// URLs from behind your TLS termination and you get a login page that mixes content, redirects to the wrong scheme, and fails in a way that looks like a Firefly bug for the first hour. The reverse-proxy hygiene in Reverse Proxy Done Right: Automatic HTTPS with Caddy applies here in full.

The cron container is mandatory

Advertisement

That third service looks like decoration. It is load-bearing, and skipping it is the most common Firefly III misconfiguration by a distance.

Firefly’s cron endpoint does three jobs: it fires recurring transactions, it processes automatic budget rollovers, and it triggers bill reminders. Without it, none of those happen, and the failure is completely silent — no error, no banner, just your salary quietly failing to appear on the 27th and you assuming you had misconfigured the recurrence.

Verify it works before you rely on it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ TOKEN=$(cat /srv/secrets/firefly_cron.txt)
$ curl -s "http://127.0.0.1:8080/api/v1/cron/$TOKEN" | jq .
{
  "recurring_transactions": {
    "job_fired": true,
    "job_succeeded": true
  },
  "auto_budgets": {
    "job_fired": true,
    "job_succeeded": true
  }
}

Two trues per job. If the token is wrong you get a 404 with no explanation, which is the correct security posture and an unhelpful debugging experience. Since this is a cron job that fails silently, it belongs behind a dead-man’s switch — the exact use case I made in Healthchecks.io Self-Hosted: Making Sure Your Cron Jobs Actually Ran.

Getting data in

Firefly III deliberately does no bank sync itself. A separate project, the Data Importer, does it — a fourth container that talks to your Firefly instance over the API and to GoCardless, SimpleFIN or a CSV file on the other side.

The separation is architecturally clean and practically annoying. It means the credentials that read your bank live in a different container from your finance data, which is good. It also means twice the configuration and two things to upgrade in step.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  importer:
    image: fireflyiii/data-importer:version-1.5.4
    container_name: firefly-importer
    environment:
      - FIREFLY_III_URL=http://firefly:8080
      - VANITY_URL=https://money.mylab.local
      - FIREFLY_III_ACCESS_TOKEN_FILE=/run/secrets/firefly_pat
      - TZ=Europe/Copenhagen
    secrets:
      - firefly_pat
    ports:
      - "127.0.0.1:8081:8080"
    restart: unless-stopped

FIREFLY_III_URL is the container-network address; VANITY_URL is what your browser sees. Getting those two the same is the classic error and produces OAuth callbacks that go nowhere.

The access token is a Personal Access Token generated in Firefly under Options → Profile → OAuth. It is a long JWT and it is a bearer credential for your entire financial history — treat it like a password.

For CSV work the importer uses a JSON configuration describing your bank’s column layout, which you build once through a wizard and then reuse forever:

1
2
3
$ docker exec firefly-importer ls -1 /var/www/html/storage/configurations
bank-current-account.json
credit-card.json

My honest workflow: I gave up on live bank sync for one of my accounts, whose PSD2 implementation re-authorises every ninety days and fails half those attempts. I download a CSV monthly and feed it to the importer with a saved configuration. It takes four minutes and it has never once broken.

Rules, which do the actual work

Like every serious finance tool, Firefly lives or dies on automatic categorisation. Its rules engine is the most capable of any I have used: triggers on any field with real operators, actions that can set categories, budgets, tags, notes, or convert a transaction’s type entirely.

That last one is the killer. A rule that recognises a payment to your own savings account and converts the transaction from a withdrawal to a transfer fixes the single biggest source of import noise:

1
2
3
4
5
Rule: "Savings transfers are not spending"
Trigger:  destination_account_is = "Savings"
          transaction_type_is    = "Withdrawal"
Actions:  convert_withdrawal_to_transfer -> "Savings"
          remove_all_categories

Rules run on import and can be run retroactively over your whole history, which is how you fix three years of bad data in one afternoon. Do that on a copy first. A rule with a slightly wrong trigger, applied to 6,000 transactions, is a thing you will want to undo, and the undo is your backup.

The parts people never find

Advertisement

Firefly III’s interface is dense enough that three of its best features go unnoticed for months. I found all three late, and each one changed how I use the thing.

Bills are recurring expected payments with an amount range and a period. Attach a rule so matching transactions link automatically, and the dashboard grows a panel showing what is expected this month, what has landed, and what has quietly stopped arriving. That last case is the valuable one: a subscription that vanishes from your statement because the card expired is invisible in every categorised pie chart and obvious in a bills list. I found two direct debits still running for services I had cancelled in 2022 this way, which paid for the evening of account modelling several times over.

Piggy banks attach a savings goal to an asset account and let you earmark part of its balance. The bicycle fund and the emergency fund can live inside one savings account without a second bank account existing. Move money in, the piggy bank fills, and the account’s “available” figure drops accordingly. It is envelope budgeting smuggled into a double-entry system, and it is the closest Firefly gets to being friendly.

Webhooks fire on transaction create, update or destroy, with a JSON body and an HMAC signature. This is the hook that makes Firefly a participant in the rest of the rack instead of an island:

1
2
3
4
5
6
7
$ # Firefly POSTs this shape to your endpoint on every new transaction
$ cat /tmp/webhook-sample.json | jq '{trigger: .trigger, amount: .content.transactions[0].amount, desc: .content.transactions[0].description}'
{
  "trigger": "STORE_TRANSACTION",
  "amount": "1249.00",
  "desc": "Annual domain renewal"
}

Mine posts to a small script that pushes a notification when any single withdrawal clears a threshold. It has caught one duplicate charge and one genuinely fraudulent one, eleven hours before the bank noticed. The signature verification is documented and worth doing, since a webhook receiver that trusts any POST is a webhook receiver someone else can lie to.

Group memberships and multi-user. Firefly supports several users on one instance, each with their own accounts, or a shared “financial administration” that two people both see. My household runs the shared model for joint accounts and separate ones for everything else. It works, and it means the awkward conversation about money happens in front of accurate numbers, which is either an improvement or a catastrophe depending on the household.

None of these are advertised on the front page. All three took me over a year to find. Read the documentation properly once, at the start — James Cole writes it himself and it is unusually good.

Troubleshooting

HTTP 500 on first boot, no useful page. Almost always APP_KEY. Check the log:

1
$ docker logs firefly 2>&1 | grep -i "app key\|MissingAppKeyException"

Login loops back to the login page. Session cookie problems from a scheme mismatch. APP_URL must be the exact external URL, TRUSTED_PROXIES=** must be set, and the proxy must forward the protocol.

Everything is slow with a big history. Firefly does a lot of aggregate SQL. My instance, with about 9,000 transactions, wanted more than Postgres’s timid defaults — the settings worth touching are the ones in Postgres Tuning for Homelabbers: Ten Settings That Matter, and work_mem in particular changed the reports page from four seconds to under one.

An upgrade fails halfway through migrations. Firefly runs migrations on boot and it upgrades cleanly if you go through the versions in order. Jumping several majors at once is unsupported. Read the release notes, take the intermediate steps, and back up first.

The importer says “Firefly III is not reachable”. FIREFLY_III_URL is wrong, or the two containers are not on the same network. Test from inside:

1
2
$ docker exec firefly-importer wget -qO- http://firefly:8080/api/v1/about | head -c 80
{"data":{"version":"6.1.21","api_version":"2.1.0","php_version":"8.3

Backups

This is a database application with a key. You need three things: the Postgres dump, the APP_KEY, and the upload volume. Miss the key and the dump is partially unreadable.

1
2
3
$ docker exec firefly-db pg_dump -U firefly -Fc firefly > /srv/backup/firefly-$(date +%F).dump
$ cp /srv/secrets/firefly_app_key.txt /srv/backup/
$ restic backup /srv/backup /srv/appdata/firefly/upload

Firefly also exports to CSV from the UI, which is a decent belt-and-braces artefact that any spreadsheet can read in twenty years. The mechanics of dumps versus WAL versus point-in-time recovery are in Database Backups Done Right: Dumps, WAL, and PITR; for a personal instance a nightly dump is entirely proportionate.

The verdict

Firefly III is uncompromising software written by someone with an extremely clear idea of what it is for. It is four containers, a Laravel monolith, a mandatory cron, a separate importer, and an evening of account modelling before it does anything useful at all. Every one of those is a real cost and I am not going to pretend otherwise.

What you get is a financial record that is structurally correct, because the data model forbids the errors that make other tools’ reports quietly worthless. Three years in, I can ask it questions about 2022 and trust the answers.

Run it if you have a mortgage, a credit card, more than two accounts, and a genuine need to know where the money went. Also run it if you are the sort of person who wants the books to balance, in which case you already knew that about yourself by paragraph three.

Skip it if your goal is to spend less next month. Firefly III is a historian and it will tell you beautifully detailed stories about your past. Actual Budget will stop you buying the thing. They are complementary, which is why I run both: Firefly for the record, Actual for the decision. Two containers’ worth of RAM to answer two different questions, and after three years I have made peace with the redundancy.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.