Can I self-host Monarch Money?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Firefly III. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $14.99 a month you stop paying Monarch Money — $179.88 a year on the Core plan.

Why people pay for Monarch Money

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Monarch's product is the connection, not the spreadsheet. It signs in to every bank, card, loan and brokerage a household has, pulls yesterday's transactions in overnight, guesses the categories, and shows one net-worth number that is correct this morning without anyone typing anything. Budgets, goals, reports and shared household access sit on top of that, and the reason the subscription survives a lean month is that the sync keeps working while nobody is watching it. Every project on this page can hold the ledger. The connection is the part you take back yourself, and it is the part that costs you something.

Monarch Money plans and list prices
PlanList priceWhat it buys
Corethe plan this page prices against$14.99/moBilled monthly. $99.99 a year on the annual plan, which the page presents as $8.33 a month. Free 7-day trial with a payment method required. Net worth across linked accounts, budgets, custom reports, and savings and debt goals.
Plus$16.67/mo$199.99 a year, which the page presents as $16.67 a month billed annually; no monthly-billed price is offered for this tier. Adds retirement forecasting, business income and expenses, profit-and-loss reports and tax-prep exports, advanced investment analysis, and a will.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium

Replaced by Firefly III

One project, named before the prompt, so you know what you are about to install.

A full transaction ledger for household money, with budgets, rules and reports, and imports you drive yourself.

The closest thing to the shape Monarch actually sells: real accounts with running balances, a transaction ledger you can search, rules that categorise on the way in, recurring transactions, budgets and reports, in three containers on your own domain. AGPL-3.0, released steadily, and the best-maintained self-hosted finance manager there is. Two things have to be said in the same breath. It has no bank connection at all, so transactions arrive as CSV files you export and import, or through the separate Data Importer on a second hostname wired to a paid provider such as SimpleFIN. And it tracks what your money did rather than what a portfolio is worth this morning, so the investment analysis and retirement forecasting on Monarch's upper tier are not here in any form.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Firefly IIIour pickONE EVENING— setup effort 2 of 4

    A full transaction ledger for household money, with budgets, rules and reports, and imports you drive yourself.

    The closest thing to the shape Monarch actually sells: real accounts with running balances, a transaction ledger you can search, rules that categorise on the way in, recurring transactions, budgets and reports, in three containers on your own domain. AGPL-3.0, released steadily, and the best-maintained self-hosted finance manager there is. Two things have to be said in the same breath. It has no bank connection at all, so transactions arrive as CSV files you export and import, or through the separate Data Importer on a second hostname wired to a paid provider such as SimpleFIN. And it tracks what your money did rather than what a portfolio is worth this morning, so the investment analysis and retirement forecasting on Monarch's upper tier are not here in any form.

  2. Actual BudgetONE COMMAND— setup effort 1 of 4

    Zero-based envelope budgeting in one container, with the budget file on your disk and no subscription attached to your money.

    Second, and first if the reason you opened Monarch every week was the budget rather than the ledger. Actual is envelope budgeting done properly, one container, a local-first file that syncs through a server you run, and an install that is over before your coffee cools. It gives up ground on the parts Monarch sells hardest: no rules engine of Firefly III's depth, no multi-account reporting of the same kind, and the same bank-feed gap, reached through GoCardless or SimpleFIN and paid for separately. Pick it when the method is the point and the archive is not.

The swap

You're paying

Monarch Money

$14.99/mo · $179.88/yr

is replaced by

You'd run

Firefly III

ONE EVENING · ~90 min to running · 2048 MB RAM

Monarch Money Core · vendor list price · checked 2026-08-07 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same Firefly III: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

329 lines · 14,640 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Firefly III 6.6.6 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
It becomes `APP_URL`, which Firefly III builds every link and form action from, and its A
record has to point at this server already.

Firefly III with its database needs 2048 MB of RAM available and 10 GB free on /srv. All three
images publish amd64 and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop.

Say this to the user before step 2, because it is the expectation this install does not meet:
Firefly III has no bank connection. Transactions arrive as CSV files exported from the bank, or
through the Firefly III Data Importer, a second application on its own hostname, out of scope
here.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/firefly-iii /srv/firefly-iii/backups
sudo install -d -m 700 /srv/firefly-iii/mariadb
sudo install -d -m 750 -o 33 -g 33 /srv/firefly-iii/upload
ls -la /srv/firefly-iii
```

Assert: `backups` owned by the login user, `mariadb` at mode `700` owned by root, `upload`
owned by uid `33`. Leave `mariadb` alone: the MariaDB image chowns its own data directory on
first start. `upload` is the opposite case. The application image runs as `www-data`, uid 33,
and never chowns a mount, so a directory owned by anyone else means attachments fail to save
while nothing else looks wrong.

## 3. Secrets

Three secrets: the Laravel application key, the database password, and the token the cron
container calls the application with. Do not print any of them, do not repeat them in your
summary, do not put them in a log line. Hex, because upstream documents both `APP_KEY` and
`STATIC_CRON_TOKEN` as exactly 32 characters with special characters avoided, and
`openssl rand -hex 16` is 32 characters of `0-9a-f`.

```bash
umask 077
cat > /srv/firefly-iii/.env <<EOF
APP_URL=https://<DOMAIN>
TZ=UTC
TRUSTED_PROXIES=**
APP_KEY=$(openssl rand -hex 16)
DB_PASSWORD=$(openssl rand -hex 32)
STATIC_CRON_TOKEN=$(openssl rand -hex 16)
EOF
chmod 600 /srv/firefly-iii/.env
umask 022
ls -l /srv/firefly-iii/.env
awk -F= '/^APP_KEY=/{print "APP_KEY length: " length($2)}' /srv/firefly-iii/.env
```

Assert: mode `-rw-------`, and the length line prints `APP_KEY length: 32`. Anything else and
Firefly III refuses to boot. `TRUSTED_PROXIES=**` is upstream's documented value behind a
reverse proxy. Tell the user `APP_KEY` is the value they cannot lose: upstream's backup page
names it first, and a database restored without it is a ledger nobody can read.

## 4. compose.yml

```bash
cat > /srv/firefly-iii/compose.yml <<'EOF'
# Firefly III · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/
#   variable reference . https://github.com/firefly-iii/firefly-iii/blob/v6.6.6/.env.example
#   cron jobs .......... https://docs.firefly-iii.org/how-to/firefly-iii/advanced/cron/
#
# Three services. `app` is nginx and PHP-FPM in one image, running as www-data,
# uid 33, which never chowns a mount, so step 2 hands it /srv/firefly-iii/upload
# already owned by 33. `db` is the MariaDB upstream's compose file uses, pinned
# to a version instead of the moving `lts` tag. `cron` is BusyBox crond calling
# the application's own cron endpoint daily, because upstream states the image
# runs no scheduler; upstream's cron container installs tzdata at every start,
# dropped here in favour of one fixed timezone. Every ${...} comes from
# /srv/firefly-iii/.env, mode 600. Digests read 2026-08-07, all three multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: firefly
      MARIADB_USER: firefly
      MARIADB_PASSWORD: ${DB_PASSWORD}
      # Upstream's database.env has the image invent a root password rather
      # than store one. It lands in this container's log once and is never used.
      MARIADB_RANDOM_ROOT_PASSWORD: "true"
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/firefly-iii/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  app:
    image: fireflyiii/core:version-6.6.6@sha256:ae69fdd95cdef9038cd7a460a5aec731f14813973e4f096511d5a4ea9ff0e972
    restart: unless-stopped
    env_file: /srv/firefly-iii/.env
    environment:
      APP_ENV: production
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: firefly
      DB_USERNAME: firefly
      # Laravel's log mailer, upstream's own default: nothing waits on SMTP.
      MAIL_MAILER: log
      # The image's health check curls the path this names. Its default,
      # /healthcheck, is not a route here; /health answers with `OK`.
      HEALTHCHECK_PATH: /health
    volumes:
      - /srv/firefly-iii/upload:/var/www/html/storage/upload
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8155.
      - "127.0.0.1:8155:8080"
    depends_on:
      db:
        condition: service_healthy

  cron:
    image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
    restart: unless-stopped
    env_file: /srv/firefly-iii/.env
    # 03:00 daily: recurring transactions, auto-budgets, rates, bill warnings.
    # The doubled $$ is compose's escape for one $, so the token is read inside
    # the container and never appears here. TZ is UTC, so BusyBox needs no tzdata.
    command: ["sh", "-c", "echo '0 3 * * * wget -qO- http://app:8080/api/v1/cron/'$$STATIC_CRON_TOKEN | crontab - && crond -f -L /dev/stdout"]
    depends_on:
      app:
        condition: service_started
EOF
cd /srv/firefly-iii && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port, no database port.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-firefly-iii
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Firefly III · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and if the two disagree the forms post to an address
# that does not answer.

<DOMAIN> {
	# Statements and attachments travel both ways, so compression earns a place.
	encode zstd gzip

	header {
		# A ledger of every account you own is worth a downgrade attack.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "same-origin"
		-Server
	}

	# 8155 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8155
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-firefly-iii, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it. Nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent: on a Prompt Zero box they change nothing.

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the way in, 443/udp is
HTTP/3. 8155 is bound to 127.0.0.1 and 3306 is never published, so neither belongs here.
Assert: `Status: active`, rules for 80, 443/tcp and 443/udp, nothing for 8155 or 3306.

## 7. Start and verify

Firefly III builds its schema on the way up, and on an empty database that takes a minute or
two, during which the site answers 500. The loop waits it out.

```bash
cd /srv/firefly-iii
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/login | grep -c 'Sign in to start your session'
docker compose exec -T cron sh -c 'wget -qO- http://app:8080/api/v1/cron/$STATIC_CRON_TOKEN'
```

Assert all four, printing what you got for each. The loop ends on `200`. Health answers
with the two letters `OK`. The grep prints `1`, meaning the first screen at
https://<DOMAIN>/login carries the heading `Sign in to start your session`. The last command
prints JSON containing `"recurring_transactions"` and `"job_fired":true`, proving the cron
container reaches the application and its token is accepted. If any of the four misses, stop,
run `docker compose logs --tail 40 app` and `docker compose logs --tail 20 db`, and name the
likely cause: a database that never reports healthy points at step 2; a `500` that never clears
points at an `APP_KEY` that is not 32 characters; a `404` in place of health means Caddy is not
reaching the container. A running container is not success.

STOP: tell the user to open https://<DOMAIN>/register, create the first account with an email
address and a password they put in their password manager, and wait.
Do not continue until they confirm. This install sends no mail, so there is no password reset
and that password is the only way back in.

Once they confirm, prove registration closed behind them.

```bash
curl -sS https://<DOMAIN>/register | grep -c 'Registration is currently not available'
```

Assert: that prints `1`. Firefly III ships in single-user mode, so the register page serves a
form while the database holds no users and refuses everyone after the first.

## 8. First backup and restore

Three artifacts, taken before the user enters a transaction. The dump holds accounts,
transactions, budgets and rules; the upload archive holds attachments; the config archive holds
the files that rebuild the service around them, `.env` among them, where `APP_KEY` lives.

```bash
cd /srv/firefly-iii
docker compose exec -T db sh -c 'exec mariadb-dump -ufirefly -p"$MARIADB_PASSWORD" --single-transaction firefly' | gzip > /srv/firefly-iii/backups/firefly-iii-db-$(date +%F).sql.gz
sudo tar -czf /srv/firefly-iii/backups/firefly-iii-upload-$(date +%F).tar.gz -C /srv/firefly-iii upload
sudo tar -czf /srv/firefly-iii/backups/firefly-iii-config-$(date +%F).tar.gz -C /srv/firefly-iii compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/firefly-iii/backups/
```

Assert: all three exist, none is empty, print all three sizes. Nothing is stopped:
`--single-transaction` snapshots a running InnoDB database consistently, and the password is
read inside the container from its own environment, so it never reaches this terminal.

A backup on the same disk as the data is not a backup. Run this one from the user's machine,
not the server:

```bash
mkdir -p ~/backups/firefly-iii
scp vps:/srv/firefly-iii/backups/* ~/backups/firefly-iii/
```

To restore, in this order: `docker compose down`, then untar the config archive into
/srv/firefly-iii so `.env` is back before any container starts, because MariaDB reads its
password from that file the moment it initialises an empty data directory. Then
`sudo rm -rf /srv/firefly-iii/mariadb`, recreate it as in step 2, `docker compose up -d db`,
wait about 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -ufirefly -p"$MARIADB_PASSWORD" firefly'`, untar
the upload archive back, then `docker compose up -d`. Tell the user a dump without `.env` is
not a restore.

## 9. Updating later

New versions are listed at https://github.com/firefly-iii/firefly-iii/releases; the image tag
for a release is `version-` plus its number. Take all three backup artifacts first, then edit
the image line in /srv/firefly-iii/compose.yml to the new tag and digest:

```bash
cd /srv/firefly-iii
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

Firefly III migrates its own database on the way up, so watch that log until it settles, then
re-run the health check from step 7 before calling the update done.

## 10. What will probably go wrong

The first `docker compose up -d` looks like a failed install for about two minutes. I opened
https://<DOMAIN> the second the command returned, got a blank Laravel error page, reloaded
twice, and started reading the compose file for a typo that was not there. Firefly III was
building its schema: dozens of migrations run on the first boot, and until the last one lands
every request answers 500. The loop in step 7 exists so nobody guesses. If it still fails
after forty attempts, check the `APP_KEY` length line from step 3.

## 11. Out of scope

- Do not install the Firefly III Data Importer. It is a separate application with its own
  container, hostname and access token; this prompt installs the ledger it would feed.
- Do not configure SMTP. `MAIL_MAILER` is `log` here, upstream's own default.
- Do not set `FIREFLY_III_LAYOUT=v2`. Upstream calls that layout very experimental and warns
  about data loss in the same breath.
- Do not regenerate `APP_KEY` or run `php artisan key:generate` here. The key that encrypted
  the data is the only key that reads it back.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Firefly III 6.6.6 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1, because it is the expectation Firefly III does not meet. It has no
bank connection. Nothing you install here logs into a bank, and no transaction appears by
itself. You export a CSV from your bank and import it, or you type transactions in, or you run
the separate Firefly III Data Importer on a second hostname and pay a data provider such as
SimpleFIN. That last option is not part of this install.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of
available RAM, stop and resize the box rather than watching PHP and MariaDB fight the OOM
killer during your first import.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/firefly-iii /srv/firefly-iii/backups
sudo install -d -m 700 /srv/firefly-iii/mariadb
sudo install -d -m 750 -o 33 -g 33 /srv/firefly-iii/upload
ls -la /srv/firefly-iii
```

You should see: `backups` owned by you, `mariadb` at mode `drwx------` owned by root, and
`upload` owned by uid `33`.

If you do not: leave `mariadb` owned by root on purpose, because the MariaDB image chowns its
own data directory the first time it starts and one you have already chowned makes it refuse to
initialise. `upload` is the opposite case. The Firefly III image runs as `www-data`, uid 33,
from its first instruction and never chowns a mount, so if that folder belongs to you, saving
an attachment fails with a permission error while every other page keeps working.

## 3. Secrets

Three secrets, all generated here on the server: the Laravel application key, the database
password, and the token the cron container uses to call the application. Hex for all three,
because upstream documents `APP_KEY` and `STATIC_CRON_TOKEN` as exactly 32 characters with
special characters avoided, and `openssl rand -hex 16` is 32 characters of `0-9a-f`.

```bash
umask 077
cat > /srv/firefly-iii/.env <<EOF
APP_URL=https://<DOMAIN>
TZ=UTC
TRUSTED_PROXIES=**
APP_KEY=$(openssl rand -hex 16)
DB_PASSWORD=$(openssl rand -hex 32)
STATIC_CRON_TOKEN=$(openssl rand -hex 16)
EOF
chmod 600 /srv/firefly-iii/.env
umask 022
ls -l /srv/firefly-iii/.env
awk -F= '/^APP_KEY=/{print "APP_KEY length: " length($2)}' /srv/firefly-iii/.env
```

You should see: mode `-rw-------`, your own username twice, and then `APP_KEY length: 32`.
Replace `<DOMAIN>` on the first line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/firefly-iii/.env` and
carry on. A length other than 32 means Firefly III will refuse to boot with a key-length error;
delete the file and paste the block again in one go. If the file already existed from an
earlier attempt, this block has now replaced all three secrets, which is fine before the
database exists and a problem afterwards: MariaDB keeps the password it was created with, and a
changed `APP_KEY` makes previously encrypted data unreadable.

Do not paste that file, any of the three secrets, or any command output containing them into
this chat window. `APP_KEY` is the one worth being careful about twice: upstream's backup page
names it first, and a database restored without it is a ledger nobody can read.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/firefly-iii/compose.yml <<'EOF'
# Firefly III · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/
#   variable reference . https://github.com/firefly-iii/firefly-iii/blob/v6.6.6/.env.example
#   cron jobs .......... https://docs.firefly-iii.org/how-to/firefly-iii/advanced/cron/
#
# Three services. `app` is nginx and PHP-FPM in one image, running as www-data,
# uid 33, which never chowns a mount, so step 2 hands it /srv/firefly-iii/upload
# already owned by 33. `db` is the MariaDB upstream's compose file uses, pinned
# to a version instead of the moving `lts` tag. `cron` is BusyBox crond calling
# the application's own cron endpoint daily, because upstream states the image
# runs no scheduler; upstream's cron container installs tzdata at every start,
# dropped here in favour of one fixed timezone. Every ${...} comes from
# /srv/firefly-iii/.env, mode 600. Digests read 2026-08-07, all three multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: firefly
      MARIADB_USER: firefly
      MARIADB_PASSWORD: ${DB_PASSWORD}
      # Upstream's database.env has the image invent a root password rather
      # than store one. It lands in this container's log once and is never used.
      MARIADB_RANDOM_ROOT_PASSWORD: "true"
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/firefly-iii/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  app:
    image: fireflyiii/core:version-6.6.6@sha256:ae69fdd95cdef9038cd7a460a5aec731f14813973e4f096511d5a4ea9ff0e972
    restart: unless-stopped
    env_file: /srv/firefly-iii/.env
    environment:
      APP_ENV: production
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: firefly
      DB_USERNAME: firefly
      # Laravel's log mailer, upstream's own default: nothing waits on SMTP.
      MAIL_MAILER: log
      # The image's health check curls the path this names. Its default,
      # /healthcheck, is not a route here; /health answers with `OK`.
      HEALTHCHECK_PATH: /health
    volumes:
      - /srv/firefly-iii/upload:/var/www/html/storage/upload
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8155.
      - "127.0.0.1:8155:8080"
    depends_on:
      db:
        condition: service_healthy

  cron:
    image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
    restart: unless-stopped
    env_file: /srv/firefly-iii/.env
    # 03:00 daily: recurring transactions, auto-budgets, rates, bill warnings.
    # The doubled $$ is compose's escape for one $, so the token is read inside
    # the container and never appears here. TZ is UTC, so BusyBox needs no tzdata.
    command: ["sh", "-c", "echo '0 3 * * * wget -qO- http://app:8080/api/v1/cron/'$$STATIC_CRON_TOKEN | crontab - && crond -f -L /dev/stdout"]
    depends_on:
      app:
        condition: service_started
EOF
cd /srv/firefly-iii && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/firefly-iii/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/firefly-iii/compose.yml` and paste again in one go. The three services are the
application, the MariaDB it stores everything in, and a small cron container. That third one
exists because upstream states plainly that the Docker image does not run scheduled jobs, so
without it recurring transactions never fire and auto-budgets never roll over.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-firefly-iii
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Firefly III · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and if the two disagree the forms post to an address
# that does not answer.

<DOMAIN> {
	# Statements and attachments travel both ways, so compression earns a place.
	encode zstd gzip

	header {
		# A ledger of every account you own is worth a downgrade attack.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "same-origin"
		-Server
	}

	# 8155 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8155
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-firefly-iii /etc/caddy/Caddyfile`,
reload, and paste again. The hostname in this block has to be the same one you put in `APP_URL`
in step 3, because Firefly III builds its own links and form actions from `APP_URL`; if the two
disagree, the login page renders and the login button posts into nothing.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8155` or `3306`.

If you do not: delete anything for `8155` or `3306` with `sudo ufw delete allow 8155`. 8155 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to answer the ACME challenge and
redirect to HTTPS, 443/tcp is the way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

Firefly III builds its schema on the way up. On an empty database that is a minute or two
during which every page answers 500, so the loop below waits it out.

```bash
cd /srv/firefly-iii
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/login | grep -c 'Sign in to start your session'
docker compose exec -T cron sh -c 'wget -qO- http://app:8080/api/v1/cron/$STATIC_CRON_TOKEN'
```

You should see, in order: the loop climbing and ending on `200`; the two letters `OK`; then
`1`; then a line of JSON containing `"recurring_transactions"` and `"job_fired":true`.

If you do not: the `1` is the first screen. It means https://<DOMAIN>/login carries the heading
`Sign in to start your session`, which is what a browser will show you. A `0` there with a
`200` from health usually means Caddy is serving a different site block, so check the hostname
you pasted in step 5. If the loop never reaches `200`, run `docker compose logs --tail 20 db`
first, because a database that never reports healthy is step 2 done wrong, and
`docker compose logs --tail 40 app` second, where a key-length complaint sends you back to step
3. The JSON line is the cron container proving it can reach the application and that its token
is accepted; without it, recurring transactions would silently never run.

Now open https://<DOMAIN>/register in a browser, create the first account with an email address
and a password you put in your password manager, and come back. There is no password reset on
this install, because it sends no mail, so that password is the only way back in.

```bash
curl -sS https://<DOMAIN>/register | grep -c 'Registration is currently not available'
```

You should see: `1`.

If you do not: a `0` means the register page is still serving a form, which means the account
was not created. Firefly III ships in single-user mode, so registration is open exactly until
the database holds one user and closes itself after that. Create the account, then run the same
command again. Do not call the install done while that number is `0`: a register form on a
public hostname is an invitation. A running container is not success either.

## 8. First backup and restore

Three artifacts, taken before you enter a single transaction. The dump holds accounts,
transactions, budgets and rules; the upload archive holds attachments; the config archive holds
the files that rebuild the service around them, `.env` among them, where `APP_KEY` lives.

```bash
cd /srv/firefly-iii
docker compose exec -T db sh -c 'exec mariadb-dump -ufirefly -p"$MARIADB_PASSWORD" --single-transaction firefly' | gzip > /srv/firefly-iii/backups/firefly-iii-db-$(date +%F).sql.gz
sudo tar -czf /srv/firefly-iii/backups/firefly-iii-upload-$(date +%F).tar.gz -C /srv/firefly-iii upload
sudo tar -czf /srv/firefly-iii/backups/firefly-iii-config-$(date +%F).tar.gz -C /srv/firefly-iii compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/firefly-iii/backups/
```

You should see: three files, the dump a few tens of kilobytes on a fresh install and the two
archives smaller. Nothing goes offline: `--single-transaction` snapshots a running InnoDB
database consistently, and the password never appears in your terminal because the container
reads it from its own environment.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump`
failed and the shell created the file anyway. Run the dump line without `| gzip` to read the
error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/firefly-iii
scp vps:/srv/firefly-iii/backups/* ~/backups/firefly-iii/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/firefly-iii/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is an empty ledger:

```bash
cd /srv/firefly-iii
docker compose down
sudo rm -rf /srv/firefly-iii/mariadb
sudo install -d -m 700 /srv/firefly-iii/mariadb
docker compose up -d db
sleep 40
gunzip -c /srv/firefly-iii/backups/firefly-iii-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -ufirefly -p"$MARIADB_PASSWORD" firefly'
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/health
```

You should see: no output from the restore itself, then `OK` from the last command, which means
the schema came back into a database that had been deleted and rebuilt.

If you do not: `Access denied for user 'firefly'` means the new empty data directory did not
get the password, which means `.env` was missing when the container first started. That is why
the config archive is restored before anything else on a real restore, and why a dump without
`.env` is not a restore. Understand the stakes before you skip this step: this database is
every account balance and every transaction you will ever put in, and a bank will not export
you a second copy of a category you invented.

## 9. Updating later

New versions are listed at https://github.com/firefly-iii/firefly-iii/releases, and the image
tag for a release is `version-` followed by its number. Take all three backup artifacts first,
then edit the `image:` line in /srv/firefly-iii/compose.yml to the new tag and its digest.

```bash
cd /srv/firefly-iii
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and open one account page as well,
because a service that answers `OK` on health can still be failing a page if a migration
stopped halfway.

## 10. What will probably go wrong

The first `docker compose up -d` looks like a failed install for about two minutes. I opened
https://<DOMAIN> the second the command returned, got a blank Laravel error page, reloaded
twice, and started reading the compose file for a typo that was not there. Firefly III was
building its schema: dozens of migrations run on the first boot, and until the last one lands
every request answers 500. The loop in step 7 exists so nobody guesses. If it still fails
after forty attempts, check the `APP_KEY` length line from step 3.

## 11. Out of scope

- Do not install the Firefly III Data Importer. It is a separate application with its own
  container, hostname and access token; this install gives you the ledger it would feed.
- Do not configure SMTP. `MAIL_MAILER` is `log` here, upstream's own default.
- Do not set `FIREFLY_III_LAYOUT=v2`. Upstream calls that layout very experimental and warns
  about data loss in the same breath.
- Do not regenerate `APP_KEY` or run `php artisan key:generate` here. The key that encrypted
  the data is the only key that reads it back.

326 lines · 14,991 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Firefly III 6.6.6, with the MariaDB it keeps the ledger in, under
~/selfhost/firefly-iii, answering at http://localhost:8155.

## 1. Preflight

Say this to the user before step 2; it decides whether they want this install. Firefly III has
no bank connection: transactions arrive as CSV exports from the bank, or typed in. The ledger
answers at http://localhost:8155, this computer only, so a phone cannot record a purchase. And
the daily 03:00 job behind recurring transactions, auto-budgets and bill warnings fires only
while this computer is awake with Docker running.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This install needs 2048 MB of RAM
available and 10 GB free on the home disk; all three images publish amd64 and arm64. On macOS
and Windows that figure is the host's, and Docker Desktop takes a share. Under either floor,
print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/firefly-iii/backups
ls -la ~/selfhost/firefly-iii
```

Assert: `backups` exists, owned by the user. Nothing else belongs here: the database and the
attachment store are Docker volumes, because MariaDB and the application image each claim their
own directory at a uid a home-folder bind cannot grant on Windows.

## 4. Secrets

Three secrets: the application key, the database password, and the cron token. Print none of
them, in your summary or any log line. Hex, because upstream documents `APP_KEY` and
`STATIC_CRON_TOKEN` as exactly 32 characters with special characters avoided, and
`openssl rand -hex 16` gives 32 of `0-9a-f`.

```bash
umask 077
cat > ~/selfhost/firefly-iii/.env <<EOF
APP_URL=http://localhost:8155
TZ=UTC
APP_KEY=$(openssl rand -hex 16)
DB_PASSWORD=$(openssl rand -hex 32)
STATIC_CRON_TOKEN=$(openssl rand -hex 16)
EOF
chmod 600 ~/selfhost/firefly-iii/.env
umask 022
ls -l ~/selfhost/firefly-iii/.env
awk -F= '/^APP_KEY=/{print "APP_KEY length: " length($2)}' ~/selfhost/firefly-iii/.env
```

Assert: mode `-rw-------`, and the length line prints `APP_KEY length: 32`; anything else and
Firefly III refuses to boot. Git Bash ships openssl; on Windows the mode bits are advisory. Tell
the user `APP_KEY` is the value they cannot lose: a database restored without it is unreadable.

## 5. compose.yml

```bash
cat > ~/selfhost/firefly-iii/compose.yml <<'EOF'
# Firefly III · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/
#   variable reference . https://github.com/firefly-iii/firefly-iii/blob/v6.6.6/.env.example
#   cron jobs .......... https://docs.firefly-iii.org/how-to/firefly-iii/advanced/cron/
#
# Three services on the computer you are sitting at, .env read from
# ~/selfhost/firefly-iii/ so one file works on macOS, Linux and Windows. Both
# data directories are named volumes, not home-folder binds: MariaDB and the
# app image each claim their own directory at a uid Docker Desktop cannot grant
# on a Windows home folder. `cron` is BusyBox crond calling the app's cron
# endpoint daily; upstream states the image runs no scheduler. Digests read
# 2026-08-07, all three multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: firefly
      MARIADB_USER: firefly
      MARIADB_PASSWORD: ${DB_PASSWORD}
      # Upstream's database.env invents a root password rather than store one.
      MARIADB_RANDOM_ROOT_PASSWORD: "true"
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - firefly-iii-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  app:
    image: fireflyiii/core:version-6.6.6@sha256:ae69fdd95cdef9038cd7a460a5aec731f14813973e4f096511d5a4ea9ff0e972
    restart: unless-stopped
    env_file: ./.env
    environment:
      APP_ENV: production
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: firefly
      DB_USERNAME: firefly
      # Laravel's log mailer, upstream's own default: nothing waits on SMTP.
      MAIL_MAILER: log
      # The image's health check curls the path this names. Its default,
      # /healthcheck, is not a route here; /health answers with `OK`.
      HEALTHCHECK_PATH: /health
    volumes:
      - firefly-iii-upload:/var/www/html/storage/upload
    ports:
      # Loopback only: no other device on the wifi can reach 8155.
      - "127.0.0.1:8155:8080"
    depends_on:
      db:
        condition: service_healthy

  cron:
    image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
    restart: unless-stopped
    env_file: ./.env
    # 03:00 daily: recurring transactions, auto-budgets, rates, bill warnings.
    # The doubled $$ is compose's escape for one $, so the token is read inside
    # the container. TZ is UTC here, so BusyBox needs no tzdata.
    command: ["sh", "-c", "echo '0 3 * * * wget -qO- http://app:8080/api/v1/cron/'$$STATIC_CRON_TOKEN | crontab - && crond -f -L /dev/stdout"]
    depends_on:
      app:
        condition: service_started

volumes:
  firefly-iii-db:
  firefly-iii-upload:
EOF
cd ~/selfhost/firefly-iii && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one port, two named volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. No hostname means
no DNS to wait for. No certificate, because one attests a public name and nothing here has one;
browsers treat http://localhost as a secure context anyway. No firewall rule, because nothing
leaves loopback: 8155 is on 127.0.0.1, not the phone, not the wifi, not the internet.

```bash
grep -c '"127.0.0.1:' ~/selfhost/firefly-iii/compose.yml
```

Assert: `1`, the single published port. MariaDB never publishes one.

## 7. Start and verify

Firefly III builds its schema on the way up: on an empty database, a minute or two of
answering 500. The loop waits it out.

```bash
cd ~/selfhost/firefly-iii
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8155/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8155/health
curl -sS http://localhost:8155/login | grep -c 'Sign in to start your session'
docker compose exec -T cron sh -c 'wget -qO- http://app:8080/api/v1/cron/$STATIC_CRON_TOKEN'
```

Assert all four, printing what you got: the loop ends on `200`; health answers with the two
letters `OK`; the grep prints `1`, meaning the first screen at http://localhost:8155/login
carries the heading `Sign in to start your session`; the last command prints JSON containing
`"job_fired":true`, proving the cron container reaches the app with an accepted token. If any
of the four misses, stop, run `docker compose logs --tail 40 app`, and name the cause: a
database that never reports healthy means step 4 wrote no `DB_PASSWORD`; a `500` that never
clears means a wrong-length `APP_KEY`; `port is already allocated` means something else holds
8155 (`lsof -nP -iTCP:8155 -sTCP:LISTEN`). A running container is not success.

STOP: tell the user to open http://localhost:8155/register, create the first account with an
email and a password they put in their password manager, and wait.
Do not continue until they confirm. No mail is sent here, so that password is the only way
back in.

Once they confirm, prove registration closed behind them.

```bash
curl -sS http://localhost:8155/register | grep -c 'Registration is currently not available'
```

Assert: that prints `1`. Firefly III ships in single-user mode: the register page serves a form
while the database holds no users, and refuses everyone after the first.

## 8. First backup and restore

Three artifacts: the dump with accounts, transactions, budgets and rules; the attachments read
inside the container that owns them; and the two config files.

```bash
cd ~/selfhost/firefly-iii
docker compose exec -T db sh -c 'exec mariadb-dump -ufirefly -p"$MARIADB_PASSWORD" --single-transaction firefly' | gzip > ~/selfhost/firefly-iii/backups/firefly-iii-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html/storage -czf - upload > ~/selfhost/firefly-iii/backups/firefly-iii-upload-$(date +%F).tar.gz
tar -C ~/selfhost/firefly-iii -czf ~/selfhost/firefly-iii/backups/firefly-iii-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/firefly-iii/backups/
```

Assert: all three exist, none is empty, print all three sizes. Nothing is stopped:
`--single-transaction` snapshots a running InnoDB database consistently, and the password stays
inside the container.

All three sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a synced folder or a USB
stick, copy all three there with `cp`, and assert they confirm all three are listed. In Git Bash
a Windows drive is `/d/Backups`, not `D:\Backups`.

To restore: untar the config archive into ~/selfhost/firefly-iii first, because MariaDB reads
its password from .env the moment it initialises an empty volume and Firefly III reads
`APP_KEY` from the same file. Then `docker compose down -v`, the one place `-v` belongs because
it drops the old volume on purpose; `docker compose up -d db`; 30 seconds for healthy;
`gunzip -c` the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -ufirefly -p"$MARIADB_PASSWORD" firefly'`;
`docker compose up -d`; then the upload archive through
`docker compose exec -T app sh -c 'tar -C /var/www/html/storage -xzf -'`. A dump without .env
is not a restore.

## 9. Updating later

New versions are at https://github.com/firefly-iii/firefly-iii/releases; a release's image tag
is `version-` plus its number. Back up first, then edit the image line in
~/selfhost/firefly-iii/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/firefly-iii
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

Watch that log until it settles, then re-run step 7's health check.

## 10. What will probably go wrong

I rebooted this machine, opened the ledger to log a coffee, and got a connection refused that
reads like a lost database. It was not: Docker Desktop had not started with the session, so
nothing was listening on 8155 and the 03:00 job had not run either, the quieter half of the
same problem. `restart: unless-stopped` acts only once the Docker daemon is up. Turn on
start-at-login, and after a reboot run `docker compose up -d` in that folder first.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8155 to 0.0.0.0 for a phone on the wifi. That puts a ledger of every account
  the user owns on every network they join.
- Do not install the Firefly III Data Importer. It is a separate application with its own
  container and token; this prompt installs the ledger it would feed.
- Do not regenerate `APP_KEY`. The key that encrypted the data is the only one that reads it.
compose.local.ymlthe services, pinned · local layout76 lines

authored from upstream docs, never pasted · 3,172 bytes

# Firefly III · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/
#   variable reference . https://github.com/firefly-iii/firefly-iii/blob/v6.6.6/.env.example
#   cron jobs .......... https://docs.firefly-iii.org/how-to/firefly-iii/advanced/cron/
#
# Three services on the computer you are sitting at, .env read from
# ~/selfhost/firefly-iii/ so one file works on macOS, Linux and Windows. Both
# data directories are named volumes, not home-folder binds: MariaDB and the
# app image each claim their own directory at a uid Docker Desktop cannot grant
# on a Windows home folder. `cron` is BusyBox crond calling the app's cron
# endpoint daily; upstream states the image runs no scheduler. Digests read
# 2026-08-07, all three multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: firefly
      MARIADB_USER: firefly
      MARIADB_PASSWORD: ${DB_PASSWORD}
      # Upstream's database.env invents a root password rather than store one.
      MARIADB_RANDOM_ROOT_PASSWORD: "true"
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - firefly-iii-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  app:
    image: fireflyiii/core:version-6.6.6@sha256:ae69fdd95cdef9038cd7a460a5aec731f14813973e4f096511d5a4ea9ff0e972
    restart: unless-stopped
    env_file: ./.env
    environment:
      APP_ENV: production
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: firefly
      DB_USERNAME: firefly
      # Laravel's log mailer, upstream's own default: nothing waits on SMTP.
      MAIL_MAILER: log
      # The image's health check curls the path this names. Its default,
      # /healthcheck, is not a route here; /health answers with `OK`.
      HEALTHCHECK_PATH: /health
    volumes:
      - firefly-iii-upload:/var/www/html/storage/upload
    ports:
      # Loopback only: no other device on the wifi can reach 8155.
      - "127.0.0.1:8155:8080"
    depends_on:
      db:
        condition: service_healthy

  cron:
    image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
    restart: unless-stopped
    env_file: ./.env
    # 03:00 daily: recurring transactions, auto-budgets, rates, bill warnings.
    # The doubled $$ is compose's escape for one $, so the token is read inside
    # the container. TZ is UTC here, so BusyBox needs no tzdata.
    command: ["sh", "-c", "echo '0 3 * * * wget -qO- http://app:8080/api/v1/cron/'$$STATIC_CRON_TOKEN | crontab - && crond -f -L /dev/stdout"]
    depends_on:
      app:
        condition: service_started

volumes:
  firefly-iii-db:
  firefly-iii-upload:

agent-readable mirror: /self-host/monarch-money.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned74 lines

authored from upstream docs, never pasted · 3,397 bytes

# Firefly III · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/
#   variable reference . https://github.com/firefly-iii/firefly-iii/blob/v6.6.6/.env.example
#   cron jobs .......... https://docs.firefly-iii.org/how-to/firefly-iii/advanced/cron/
#
# Three services. `app` is nginx and PHP-FPM in one image, running as www-data,
# uid 33, which never chowns a mount, so step 2 hands it /srv/firefly-iii/upload
# already owned by 33. `db` is the MariaDB upstream's compose file uses, pinned
# to a version instead of the moving `lts` tag. `cron` is BusyBox crond calling
# the application's own cron endpoint daily, because upstream states the image
# runs no scheduler; upstream's cron container installs tzdata at every start,
# dropped here in favour of one fixed timezone. Every ${...} comes from
# /srv/firefly-iii/.env, mode 600. Digests read 2026-08-07, all three multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: firefly
      MARIADB_USER: firefly
      MARIADB_PASSWORD: ${DB_PASSWORD}
      # Upstream's database.env has the image invent a root password rather
      # than store one. It lands in this container's log once and is never used.
      MARIADB_RANDOM_ROOT_PASSWORD: "true"
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/firefly-iii/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other containers.

  app:
    image: fireflyiii/core:version-6.6.6@sha256:ae69fdd95cdef9038cd7a460a5aec731f14813973e4f096511d5a4ea9ff0e972
    restart: unless-stopped
    env_file: /srv/firefly-iii/.env
    environment:
      APP_ENV: production
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_DATABASE: firefly
      DB_USERNAME: firefly
      # Laravel's log mailer, upstream's own default: nothing waits on SMTP.
      MAIL_MAILER: log
      # The image's health check curls the path this names. Its default,
      # /healthcheck, is not a route here; /health answers with `OK`.
      HEALTHCHECK_PATH: /health
    volumes:
      - /srv/firefly-iii/upload:/var/www/html/storage/upload
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8155.
      - "127.0.0.1:8155:8080"
    depends_on:
      db:
        condition: service_healthy

  cron:
    image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
    restart: unless-stopped
    env_file: /srv/firefly-iii/.env
    # 03:00 daily: recurring transactions, auto-budgets, rates, bill warnings.
    # The doubled $$ is compose's escape for one $, so the token is read inside
    # the container and never appears here. TZ is UTC, so BusyBox needs no tzdata.
    command: ["sh", "-c", "echo '0 3 * * * wget -qO- http://app:8080/api/v1/cron/'$$STATIC_CRON_TOKEN | crontab - && crond -f -L /dev/stdout"]
    depends_on:
      app:
        condition: service_started
Caddyfilethe hostname and TLS28 lines

authored from upstream docs, never pasted · 996 bytes

# Firefly III · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and if the two disagree the forms post to an address
# that does not answer.

<DOMAIN> {
	# Statements and attachments travel both ways, so compression earns a place.
	encode zstd gzip

	header {
		# A ledger of every account you own is worth a downgrade attack.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "same-origin"
		-Server
	}

	# 8155 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8155
}
install.shthe same install, no agent165 lines

authored from upstream docs, never pasted · 7,678 bytes

#!/usr/bin/env bash
# Firefly III · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=money.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/
#   https://github.com/firefly-iii/firefly-iii/blob/v6.6.6/.env.example
#   https://docs.firefly-iii.org/how-to/firefly-iii/advanced/cron/
#   https://docs.firefly-iii.org/how-to/firefly-iii/advanced/backup/
#
# Three secrets are generated here, on this machine: the Laravel application
# key, the MariaDB password, and the static cron token. All three go into
# /srv/firefly-iii/.env with mode 600 and none of them is ever printed.
#
# DOMAIN_HOST is also APP_URL, the address Firefly III builds every link and
# form action from. It has to be the hostname you pointed at this box.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/firefly-iii}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. money.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP plus MariaDB wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# mariadb stays owned by root: the MariaDB image chowns its own data directory
# the first time it starts. upload is the opposite case, because the Firefly III
# image runs as www-data (uid 33) and never chowns a mount.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mariadb"
sudo install -d -m 750 -o 33 -g 33 "$APP_DIR/upload"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex for all three: upstream documents APP_KEY and STATIC_CRON_TOKEN as exactly
# 32 characters with special characters avoided, and `openssl rand -hex 16` is
# 32 characters of 0-9a-f. Read them later with
#   sudo grep -E 'APP_KEY|DB_PASSWORD|STATIC_CRON_TOKEN' /srv/firefly-iii/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APP_URL=https://${DOMAIN_HOST}
		TZ=UTC
		TRUSTED_PROXIES=**
		APP_KEY=$(openssl rand -hex 16)
		DB_PASSWORD=$(openssl rand -hex 32)
		STATIC_CRON_TOKEN=$(openssl rand -hex 16)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

key_len="$(awk -F= '/^APP_KEY=/{print length($2)}' "$APP_DIR/.env")"
[ "$key_len" = "32" ] || die "APP_KEY is ${key_len} characters, not 32. Firefly III will not boot."

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-firefly-iii"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8155 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8155 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# Firefly III builds its schema on the way up. On an empty database that is a
# minute or two during which every page answers 500.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 app"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '^OK' \
	|| die "/health answered 200 without OK. Check: docker compose logs --tail 40 app"

# The first screen a human sees. Upstream renders this heading on /login.
curl -sS "https://${DOMAIN_HOST}/login" | grep -q 'Sign in to start your session' \
	|| die "the login page did not carry 'Sign in to start your session'. Check the Caddy site block."

# The cron container has to reach the app and be accepted, or recurring
# transactions and auto-budgets silently never run.
docker compose exec -T cron sh -c 'wget -qO- http://app:8080/api/v1/cron/$STATIC_CRON_TOKEN' \
	| grep -q '"job_fired":true' \
	|| die "the cron endpoint did not report job_fired. Check: docker compose logs --tail 20 cron"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump -ufirefly -p"$MARIADB_PASSWORD" --single-transaction firefly' \
	| gzip > "$APP_DIR/backups/firefly-iii-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/firefly-iii-upload-${STAMP}.tar.gz" -C "$APP_DIR" upload
sudo tar -czf "$APP_DIR/backups/firefly-iii-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/firefly-iii-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Firefly III is answering at https://${DOMAIN_HOST}/health

	  1. Open https://${DOMAIN_HOST}/register and create the first account now.
	     Firefly III runs in single-user mode, so registration is open until one
	     account exists and closes itself afterwards. Confirm that it closed:
	       curl -sS https://${DOMAIN_HOST}/register | grep -c 'Registration is currently not available'
	     That has to print 1 before you call this install finished.
	  2. There is no password reset, because this install sends no mail. Put the
	     password you chose in your password manager now.
	  3. Your three secrets are in $APP_DIR/.env, mode 600, and none was printed
	     here. APP_KEY is the one you cannot lose: a database restored without it
	     is a ledger nobody can read.
	  4. No bank connection. Transactions arrive as CSV files you export from
	     your bank and import, or through the separate Firefly III Data Importer,
	     which this script does not install.
	  5. First backup written to $APP_DIR/backups: a database dump, the upload
	     archive and a config archive. They are on the same disk as the data,
	     which is not a backup. Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Monarch Money.

  • No bank feeds. This is the trade, and it is the whole trade: Monarch's product is logging into your accounts overnight, and Firefly III does not log into anything. Transactions arrive as CSV files you export and import, or you type them in. Automated imports mean running the separate Data Importer on a second hostname and paying a data provider yourself, and SimpleFIN Bridge, the usual United States answer, is $1.50 a month or $15 a year for up to 25 institutions.
  • You own a MariaDB and a 32-character key. The backup is three files that travel together, because APP_KEY in .env is what makes the dump readable, and a database restored without it is a ledger nobody can open. Losing that key is worse than losing the server.
  • Nothing happens on a schedule unless a third container is running. Recurring transactions, auto-budgets, exchange rates and bill warnings all ride one daily job, and upstream states the application image runs no scheduler of its own, which is why this install ships a small cron container beside it.
  • No mail, so no password reset. The first account's password is the only way back in, and every other account is created by hand from inside the interface. The phone story is third-party apps written against the API rather than anything the project ships.
  • No investment holdings, no retirement projection, no advice. Firefly III tracks what money did, not what a portfolio is worth this morning, and the forecasting and tax-prep exports on the paid tier you are leaving are not features hiding in a settings page here.

Where this came from

“The Docker image does not support cron jobs, but the Docker Compose file includes a cron job container.”

  • Firefly III has no bank connection of its own: automated feeds run through the Data Importer, a separate application the author keeps as its own tool, pointed at a third-party provider such as SimpleFIN, GoCardless or Salt Edge. source
  • The Docker image runs no scheduler, so recurring transactions, auto-budgets, exchange rates and bill warnings fire only when something calls the cron endpoint, and STATIC_CRON_TOKEN has to be exactly 32 characters. source
  • APP_KEY must be a random string of exactly 32 characters, generated on the machine, with characters like = and # avoided. source
  • Upstream's backup page asks for the Docker variables used to launch the containers, and especially APP_KEY, alongside the upload and database volumes, and says the first thing to do after a backup is restore it. source
  • The /health route answers with the plain text OK after counting users, so it fails whenever the database is unreachable, and registration closes itself once the database holds one user because single-user mode is the default. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Monarch Money?

    Not Monarch Money itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Firefly III. A full transaction ledger for household money, with budgets, rules and reports, and imports you drive yourself. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Monarch Money?

    Firefly III. A full transaction ledger for household money, with budgets, rules and reports, and imports you drive yourself. The closest thing to the shape Monarch actually sells: real accounts with running balances, a transaction ledger you can search, rules that categorise on the way in, recurring transactions, budgets and reports, in three containers on your own domain. AGPL-3.0, released steadily, and the best-maintained self-hosted finance manager there is. Two things have to be said in the same breath. It has no bank connection at all, so transactions arrive as CSV files you export and import, or through the separate Data Importer on a second hostname wired to a paid provider such as SimpleFIN. And it tracks what your money did rather than what a portfolio is worth this morning, so the investment analysis and retirement forecasting on Monarch's upper tier are not here in any form. Also evaluated and ranked below it: Actual Budget. The prompt on this page installs Firefly III only. Firefly III is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Monarch Money?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Firefly III itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Monarch Money Core, $14.99/mo — $179.88 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Firefly III install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Firefly III on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Firefly III on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer the ledger answers at http://localhost:8155 and nowhere else, so no phone can log a purchase at the shop, and the daily job that fires recurring transactions and auto-budgets only runs on the nights the machine is awake. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.