How to Deploy Firefly III on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Self-host Firefly III, the double-entry personal finance manager, with Docker Compose and MariaDB, including the cron job and the proxy setting most installs get wrong.
- A small VPS: 1 vCPU / 1–2 GB RAM
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server
- Docker Engine + Compose installed (see the base guide below)
What Firefly III is
Firefly III is a self-hosted personal finance manager built on double-entry bookkeeping. Every transaction moves money from one account to another, so your balances always reconcile. On top of that you get budgets, categories, tags, bills and subscriptions, recurring transactions, a rule engine that tags and files transactions as they arrive, reports, and a REST API. It is a PHP (Laravel) app under the AGPL-3.0 licence.
It is not a quick-entry budgeting app. If you want to assign every dollar to an envelope and move on, Actual Budget vs Firefly III lays out the difference. Firefly III suits people who want a full ledger of their finances and are willing to set up accounts and rules to get it.
The official install is three containers: the app, a MariaDB database, and a tiny Alpine container that runs Firefly III's daily cron job. Skipping that third one is the most common mistake, because several features quietly stop working without it.
Server sizing
The catalog lists 512 MB RAM as the minimum. On our test box (GCP e2-standard-2, Ubuntu 26.04, Docker 29.8.1) the idle stack measured about 159 MB of RAM and about 2 GB of disk for the images and volumes.
- 1 vCPU / 1 GB RAM runs it comfortably for one household.
- 2 GB RAM leaves room for the optional Data Importer and a reverse proxy.
- Disk: 20 GB covers the OS, the images, the database and uploaded attachments for years.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed, along
with a non-root user and a ufw firewall. If not, start with
Docker & Compose on Ubuntu.
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
Paid link — we earn a commission if you shop through it.
Install Firefly III (Docker Compose)
Upstream's Docker docs have you download three files: the compose file from
the firefly-iii/docker repository, the app's .env.example saved as .env,
and database.env saved as .db.env. Keep those names, because the compose
file refers to them.
mkdir -p ~/firefly && cd ~/firefly
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
Now set the secrets. The docs are specific about each one:
DB_PASSWORDin.envandMYSQL_PASSWORDin.db.envmust be the same value. Set them before the first start. Once MariaDB has initialised its volume, it keeps the old password.APP_KEYmust be exactly 32 characters, with no special characters such as=or#.STATIC_CRON_TOKENmust also be exactly 32 characters. The cron container uses it to call Firefly III.TRUSTED_PROXIES=**tells Firefly III to trust the reverse proxy's headers. Without it, links and forms come out ashttp://behind HTTPS.
openssl rand -hex 16 prints exactly 32 hex characters, which meets both
length rules. Replace money.example.com with your own hostname:
cd ~/firefly
DOMAIN=money.example.com
DBPW=$(openssl rand -hex 16)
sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$DBPW|; s|^APP_KEY=.*|APP_KEY=$(openssl rand -hex 16)|; s|^STATIC_CRON_TOKEN=.*|STATIC_CRON_TOKEN=$(openssl rand -hex 16)|; s|^TRUSTED_PROXIES=.*|TRUSTED_PROXIES=**|; s|^APP_URL=.*|APP_URL=https://$DOMAIN|" .env
sed -i "s|^MYSQL_PASSWORD=.*|MYSQL_PASSWORD=$DBPW|" .db.env
grep -E '^(DB_PASSWORD|APP_KEY|STATIC_CRON_TOKEN|TRUSTED_PROXIES|APP_URL)=' .env | sed 's/=.*/=(set)/'
Also look through .env for SITE_OWNER (your email address, shown in some
error messages) and TZ (it defaults to Europe/Amsterdam).
The upstream compose file publishes the app on host port 80. Caddy needs that port, so move the app to loopback-only port 8080:
cd ~/firefly
sed -i 's|- 80:8080|- "127.0.0.1:8080:8080"|' docker-compose.yml
grep -n '8080' docker-compose.yml
Start the stack. The first boot creates the database schema, which takes a minute or two:
cd ~/firefly
docker compose -f docker-compose.yml up -d --pull=always
timeout 600 bash -c 'until curl -fsS -o /dev/null http://127.0.0.1:8080/; do sleep 5; done'
docker compose ps
docker compose logs -f app
Upstream says Firefly III "will thank you for installing it" in the log when it is ready.
HTTPS + domain
Point an A record for money.example.com at the server and wait for it to
resolve. Then put Caddy in front of the loopback port, following
Automatic HTTPS with Caddy:
money.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy sends the X-Forwarded-* headers by default, and TRUSTED_PROXIES=**
lets Firefly III believe them. If you run Caddy as a container, proxy to
firefly_iii_core:8080 on a shared Docker network instead of 127.0.0.1.
First login
Open https://money.example.com and register. The first user to register
becomes the owner, and registration is then blocked for everyone else. That
is Firefly III's default, as a security measure. If you later want separate
logins for family members, the owner can re-enable registration under
/settings → "Configuration options for Firefly III".
Then:
- Turn on two-factor authentication from your
/profilepage. The same page can log out your other sessions. - Create your asset accounts (checking, savings, cash) before you enter transactions. The upstream "first steps" tutorial walks through this.
- For bank imports, install the separate Data Importer. It has its own
compose file (
docker-compose-importer.ymlin the same repository) and handles CSV files and bank providers such as GoCardless.
The cron job
Automated budgets, recurring transactions, subscription warnings and exchange
rate updates only work while the cron job runs, according to upstream. The
cron service in the compose file handles this. At 03:00 every day, it calls
http://app:8080/api/v1/cron/<STATIC_CRON_TOKEN>. Check that it registered:
cd ~/firefly
docker compose ps cron
docker compose logs --tail 20 cron
If you would rather not run the extra container, the token on your /profile
page ("Command line token") can drive the same URL from the host's crontab.
Backups
Upstream is explicit about two things. Firefly III's export function is not a backup, and a Docker backup needs three pieces:
- the
.envand.db.envfiles (above all theAPP_KEY) - the database volume
- the upload volume
The volumes are prefixed with the project directory name. docker volume ls
shows the exact names, which here are firefly_firefly_iii_db and
firefly_firefly_iii_upload. Stop the stack for a consistent copy:
cd ~/firefly
mkdir -p ~/backups/firefly
docker compose stop
for v in firefly_iii_db firefly_iii_upload; do
docker volume inspect firefly_$v >/dev/null && \
docker run --rm -v firefly_$v:/data -v ~/backups/firefly:/backup alpine \
tar czf /backup/$v-$(date +%F).tar.gz -C /data .
done
cp .env .db.env docker-compose.yml ~/backups/firefly/
docker compose start
ls -lh ~/backups/firefly
The docker volume inspect guard matters. Upstream warns that backing up a
volume name that doesn't exist makes Docker create an empty one, and you end up
archiving nothing. Copy the backup folder off the box, then restore it once on
a scratch machine to prove it works. The docs say to test the restore before
anything else.
Upgrades
This is the upstream Docker Compose procedure:
cd ~/firefly
docker compose stop
docker compose pull
docker compose -f docker-compose.yml up -d --remove-orphans
Back up first. Upstream warns that some upgrades are destructive
migrations that can clean up or remove data. If you ever re-download
docker-compose.yml, compare the database image before you start it. A newer
file may point at a MariaDB release that cannot read your existing volume.
Troubleshooting
The app waits for db:3306 forever. The MariaDB container has exited. Its
log usually says the database is uninitialised with no password option. Check
that .db.env exists in the same folder and holds MYSQL_PASSWORD plus a root
password option.
"Access denied" for the database after changing the password. MariaDB
stored the original password in its volume on first boot. Put the old password
back, or change it inside MariaDB. Editing .db.env afterwards does nothing.
Forms and charts break behind HTTPS, or links point at http://. Upstream
lists a missing TRUSTED_PROXIES=** as the usual cause, and it also explains
Content Security Policy errors. Set it in .env and recreate the app with
docker compose up -d.
Recurring transactions never appear. The cron job isn't reaching the app.
Check docker compose logs cron, and check that STATIC_CRON_TOKEN is exactly
32 characters.
Verification + next steps
You're done when you can load https://money.example.com over a valid
certificate, register the owner account, and enable 2FA. A private window
should then show registration closed, docker compose ps should list app,
db and cron as running, and you should have an off-box backup that you have
restored once.
From there: set up rules so imported transactions file themselves, then add the Data Importer for your bank. For investments, which Firefly III does not track as a portfolio, see Deploy Ghostfolio on a VPS and Firefly III vs Ghostfolio.