Use case · Personal finance app

Firefly III self hosted on a dedicated server: Compose, owner account, cron, backups

Self-hosting Firefly III means running three containers from the project's Docker Compose file (the app, MariaDB and a small cron container) with two env files edited before the first start: .env for the app, .db.env for the database. Registration stays open only until the first account exists, so create that account over an SSH tunnel before any proxy makes the site public. This page covers the server only.

See the machines

What the Compose stack runs

ServiceImageJob
appfireflyiii/core:latestThe PHP web app on container port 8080, published by the stock file as 80:8080. Uploads in the firefly_iii_upload volume.
dbmariadb:ltsThe database, configured by .db.env, data in the firefly_iii_db volume.
cronalpineCalls /api/v1/cron/ with STATIC_CRON_TOKEN daily at 03:00, in the TZ from .env.

The docs publish no CPU or memory figure. The Docker FAQ's one resource note: if PHP exhausts its memory limit, start the container with PHP_MEMORY_LIMIT=512M or more. Our reading, not the project's: a household ledger is a light PHP-plus-MariaDB load, so disk safety and recovery decide the machine more than core count. Images are built for amd64 and arm64; 32-bit ARM is no longer supported.

Choosing the machine

Firefly III's introduction says it never contacts external servers until you tell it to. A whole machine extends that one layer down: no hypervisor, nobody else's workload on the hardware, root access, and an OS the control panel reinstalls on demand. Our dedicated server lines run on our own network, AS19624.

  • Moonshot cartridge. Listed for one service per machine, which is this stack. No cartridge has a hardware RAID controller, so off-machine backups carry the recovery plan. Only the m510 and m710x have a console.
  • Single-socket rack server. The listed base for an application with its database on the same box, with a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC): the way back in if a firewall rule cuts SSH.
  • ARM. The arm64 image suits the Ampere Altra line. The X-Gene cartridge boots only Ubuntu 16.04 and 18.04: skip it.
  • Delivery and IPv6. A racked machine is handed over in about 30 minutes once payment is approved. IPv6 is added at checkout at no charge, so the firewall rules below are written for both address families.

Install: compose file, .env, .db.env

Work as root (sudo -i) in a directory only root can read. The compose file references .env and .db.env by name, so save the raw files under exactly those names. APP_KEY must be a random string of exactly 32 characters, without =, # or similar; the generator below is the one the docs give. DB_PASSWORD in .env and MYSQL_PASSWORD in .db.env must match and must be set before the first start, because the database keeps the password in its volume. The block writes every secret with bash's builtin echo, so no value lands on a command line or in ps.

mkdir -p /opt/firefly-iii && chmod 700 /opt/firefly-iii && cd /opt/firefly-iii
curl -fsSLo docker-compose.yml https://raw.githubusercontent.com/firefly-iii/docker/main/docker-compose.yml
curl -fsSLo .env https://raw.githubusercontent.com/firefly-iii/firefly-iii/main/.env.example
curl -fsSLo .db.env https://raw.githubusercontent.com/firefly-iii/docker/main/database.env
chmod 600 .env .db.env
gen() { head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32; }
sed -i '/^APP_KEY=/d;/^DB_PASSWORD=/d;/^STATIC_CRON_TOKEN=/d;/^TRUSTED_PROXIES=/d;/^APP_URL=/d' .env
sed -i '/^MYSQL_PASSWORD=/d' .db.env
pw=$(gen)
echo "DB_PASSWORD=$pw" >> .env
echo "MYSQL_PASSWORD=$pw" >> .db.env
unset pw
echo "APP_KEY=$(gen)" >> .env
echo "STATIC_CRON_TOKEN=$(gen)" >> .env
echo 'TRUSTED_PROXIES=**' >> .env
echo 'APP_URL=https://money.example.com' >> .env
grep -cE '^(APP_KEY|STATIC_CRON_TOKEN)=[A-Za-z0-9]{32}$' .env

The last line must print 2. Edit .env by hand to set TZ (it also sets the cron container's clock), SITE_OWNER, and APP_URL to the public HTTPS address; the template says it must match the external URL.

Firewall, loopback port, first start

A port mapping with no address, like the stock 80:8080, is published on every host address, 0.0.0.0 and [::]. Published traffic skips ufw's rules on IPv4, and for IPv6 traffic as well, provided Docker's own IPv6 networking is enabled; otherwise the [::] listener is the docker-proxy process, and ufw filters that. So bind the app to 127.0.0.1 and turn ufw on, SSH only, first. Confirm docker version reports Engine 28 or newer: from 28 on, a port published on 127.0.0.1 is reachable from the machine itself only.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow 22/tcp
ufw --force enable
cd /opt/firefly-iii
sed -i 's/- 80:8080/- "127.0.0.1:8080:8080"/' docker-compose.yml
grep -n '127.0.0.1:8080' docker-compose.yml
docker compose -f docker-compose.yml up -d --pull=always
docker compose -f docker-compose.yml logs -f app

The first start migrates the database; the docs say Firefly III thanks you for installing it when it is done. Leave the log with Ctrl+C.

Registering the owner before the site is public

Firefly III opens its registration form for the first user only, and that user becomes the owner, the account that can switch multi-user mode on. Whoever reaches the form first gets it, so register while the app answers on loopback alone.

  1. On your workstation, with your own SSH login: ssh -N -L 8081:127.0.0.1:8080 you@server.example.
  2. Browse to http://localhost:8081 and register. That account is the owner.
  3. In /settings, under the configuration options, Disable user registration should stay ticked unless you mean to host further users, each with a separate administration.
  4. Close the tunnel. Only now does a proxy go in front.

Cron, reverse proxy and TRUSTED_PROXIES

Recurring transactions, auto-budgets, subscription warnings and exchange rates need the cron job. The image has no cron of its own; the compose file's cron container calls the app over the internal network at 03:00 with the STATIC_CRON_TOKEN from .env, so the token stays out of the host's crontab. It is still in the container's own crontab, and docker logs firefly_iii_cron prints the URL with the token on every run: treat that log as a secret and never paste it anywhere public. A good run shows each job with job_fired and job_succeeded. To fire it by hand, the FAQ's form is docker exec --user www-data firefly_iii_core /usr/local/bin/php /var/www/html/artisan firefly-iii:cron.

For HTTPS, run nginx or Apache on the host in front of http://127.0.0.1:8080, with a certificate for the APP_URL name. The install FAQ's nginx lines set X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Server, X-Forwarded-For and Host, raise client_max_body_size to 64M and proxy_read_timeout to 300s, and on 502 errors add larger proxy buffers. Then ufw allow 80,443/tcp: the proxy runs on the host, not in Docker, so without that rule ufw blocks every visitor and every certificate check, over IPv4 and IPv6 alike.

TRUSTED_PROXIES names the upstream allowed to set forwarded headers. The FAQ ties CSP errors and http links to leaving it unset; the .env template suggests **, which the install block wrote. That is safe only because nothing but the host reaches port 8080. For a proxy in a container, see the Nginx Proxy Manager use case.

The data importer is a separate tool

Imports are not built in. The Data Importer is its own image, fireflyiii/data-importer, kept apart by design and talking to Firefly III through the API. It reads files such as CSV and CAMT and fetches from data providers such as GoCardless and SimpleFIN. The docs have you save the project's docker-compose-importer.yml as docker-compose.yml, but that file publishes the app again as a quoted '80:8080', which the earlier sed does not match, and the importer as '81:8080', both on every address. Copy only its importer: block into your existing compose file with the port set to "127.0.0.1:8090:8080", or hand-edit both port lines to 127.0.0.1. Check with grep -n ':8080' docker-compose.yml: every port line must start with 127.0.0.1; the http://app:8080 lines are the internal address, not published ports. The importer reads .importer.env, saved from the data importer's own .env.example: set FIREFLY_III_URL=http://app:8080 and VANITY_URL to the address you browse Firefly III on, and keep the file at mode 600, since provider keys go in it. Tunnel the importer port as well (-L 8090:127.0.0.1:8090), then create the OAuth client under /profile with the callback http://localhost:8090/callback and Confidential unticked; the callback resolves on your workstation through that tunnel.

Backups and upgrades

Firefly III has no backup routine, and the docs warn that its export function is not one. Keep a database dump, the upload folder, and the env files holding APP_KEY and the passwords. Save this as /opt/firefly-iii/backup.sh, mode 700. The password expands inside the database container from its own environment and reaches the dump as MYSQL_PWD, never as an argument.

# /opt/firefly-iii/backup.sh
set -euo pipefail
out=/var/backups/firefly
day=$(date +%F)
mkdir -p "$out" && chmod 700 "$out"
cd /opt/firefly-iii
docker exec firefly_iii_db sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" exec mariadb-dump --single-transaction -u "$MYSQL_USER" "$MYSQL_DATABASE"' | gzip > "$out/db-$day.sql.gz"
docker cp firefly_iii_core:/var/www/html/storage/upload - | gzip > "$out/upload-$day.tar.gz"
tar -czf "$out/config-$day.tar.gz" docker-compose.yml .env .db.env
find "$out" -name '*.gz' -mtime +14 -delete
  • Schedule it with a file, /etc/cron.d/firefly-backup, holding one line: 30 2 * * * root umask 077; install -d -m 700 /var/backups/firefly; bash /opt/firefly-iii/backup.sh > /var/backups/firefly/run-$(date +\%F).log 2>&1.
  • Copy the archives off the machine yourself, on a schedule.
  • The docs' rule after a backup: restore it first, on a scratch machine, to prove it works.
  • Upgrade with a fresh backup in hand: stop the stack (docker compose stop), pull the images (docker compose pull), then run docker compose -f docker-compose.yml up -d --remove-orphans. The docs warn some upgrades are destructive to old data.
  • A newer compose file may carry a MariaDB version your volume is not ready for, and drops your 127.0.0.1 edit: diff before you swap.

Licence and honest limits

Firefly III and the Data Importer are licensed under the GNU Affero General Public License v3. The licence page says changes must be released under AGPLv3 too, including when a modified version runs on a web server: its users must be able to get the source. Running the stock images for yourself publishes nothing.

The FAQ is frank about hosting for others: anyone with database access can read every user's records, there are no controls against insider threat, and one administration cannot be shared between users. Treat the machine as private infrastructure for yourself or a household. The same pattern for scanned paperwork: the Paperless-ngx use case.

Questions

Does the APP_KEY really have to be 32 characters?

Yes. The docs ask for a random string of exactly 32 characters, letters and digits, avoiding =, # and similar. The Docker FAQ traces a remote_user_guard error at startup to a key of the wrong length. STATIC_CRON_TOKEN follows the same 32-character rule.

I changed the database password after the first start and now nothing connects. Why?

MariaDB stores the password in its volume on first start, so a later change in .db.env has no effect. The FAQ's fix is to stop and remove the containers, delete the upload and db volumes, and start again with the right values. On a fresh install that loses nothing; on a used one, restore from a dump afterwards.

Can several people use one Firefly III server?

Yes, as separate users with separate data. Registration closes after the owner signs up, and the owner can reopen it in the configuration options. Users cannot share one administration.

Can I use PostgreSQL instead of MariaDB?

Yes. The install page has you set DB_CONNECTION=pgsql, DB_HOST=db and DB_PORT=5432 in .env, rename the .db.env variables to POSTGRES_*, point the db service at the postgres image and change the volume's mount point. Switch the backup script to pg_dump to match.

A machine for your own ledger

Pick a line, install Ubuntu, bind the app to loopback and register the owner through a tunnel.

See the machines

Related on Server Room

Other use cases