Self-host Firefly III on a VPS with Docker
Run Firefly III on your VPS with Docker Compose: the settings to change before first start, HTTPS behind Caddy, scheduled bank imports, backups and upgrades.
What you are building
To self-host Firefly III on a VPS, you run the project's own Docker Compose file. It starts the Firefly III app, a MariaDB database, the Data Importer and a small cron container. You add one more container, Caddy, which sits in front and gets the TLS (transport layer security) certificate for HTTPS. This guide was checked against Firefly III v6.7.9 and Data Importer v2.3.5 on 27 October 2026.
Firefly III is a personal finance manager. It keeps your accounts and budgets in a database on a server you control. If you are still choosing a tool, the comparison of self-hosted budgeting apps covers the field. If you want envelope budgeting instead of a full ledger, Actual Budget on your own server is the closer fit.
You need a VPS with Docker Engine and the Compose plugin, plus a domain name. If Compose is new to you, read Docker Compose basics on a VPS first. Point a DNS (domain name system) A record, for example money.example.com, at the server's IP address before you start. Caddy asks for the certificate on its first start, and that request fails if the name does not point at the server yet.
How double entry works in Firefly III
Firefly III uses double-entry bookkeeping. Each transaction is stored twice: once as money leaving one account, and once as money arriving in another. The method was first used to catch errors and fraud, and it still lets you check that your records balance. Your own accounts, such as checking and savings, are asset accounts. A shop you pay is an expense account, and it receives withdrawals. Where income comes from, such as your employer, is a revenue account, and it is the source of deposits. A transfer moves money between two of your own asset accounts.
Here is one worked transaction. You pay 42.50 at the supermarket with your debit card. Firefly III records a withdrawal. Your checking account, an asset account, goes down by 42.50. The expense account called Supermarket goes up by 42.50. In the words of the Firefly III docs, you spend money and the supermarket "earns" it. The two sides add up to zero, so the books balance, and your reports can show both your bank balance and what you spent on groceries from the same single entry.
Download the project's Docker files
The project publishes its Compose files in the firefly-iii/docker repository on GitHub. Three details matter. The database env file is called database.env in that repository, but the Compose file reads .db.env, so save it under that name. The app's .env comes from .env.example in the main Firefly III repository. The importer's env file comes from the data-importer repository and is saved as .importer.env.
Download each file with curl -o instead of copying text from a browser. The Firefly III docs warn that the spaces in the Compose file are very important, and a copy and paste can break the indentation.
mkdir -p ~/firefly && cd ~/firefly
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/firefly-iii/docker/main/docker-compose-importer.yml
curl -fsSL -o .env https://raw.githubusercontent.com/firefly-iii/firefly-iii/main/.env.example
curl -fsSL -o .db.env https://raw.githubusercontent.com/firefly-iii/docker/main/database.env
curl -fsSL -o .importer.env https://raw.githubusercontent.com/firefly-iii/data-importer/main/.env.example
chmod 600 .env .db.env .importer.env
ls -lals -la should list docker-compose.yml and the three env files. The chmod 600 matters because these files will hold your database password and an API token.
docker-compose-importer.yml is the variant of the Compose file that already includes the Data Importer. The importer docs tell you to save it as docker-compose.yml, which is what the first curl line does. It defines four services on one Docker network called firefly_iii. The app service runs fireflyiii/core. The db service runs mariadb:lts. The importer service runs fireflyiii/data-importer. The cron service is a small Alpine container.
The cron service exists because the Firefly III image does not run scheduled jobs itself. Every day at 03:00, the cron container calls the app's cron endpoint with a token from .env. That call is what runs recurring transactions and automated budgets. If the token is wrong, those features stop working, and nothing in the browser tells you.
Settings to change before the first start, including APP_KEY
Do all of this before you run docker compose up for the first time. The database password is the one setting you cannot fix later. The Firefly III docs say it directly: change the password first. If you change it after the first run, the app loses access to the database. The documented fix is to remove the database volume, and that removes your data.
You need two random strings of exactly 32 characters, one for APP_KEY and one for STATIC_CRON_TOKEN, plus a database password. This command comes from the Firefly III docs. It prints letters and digits only, because the docs say to avoid special characters such as = and #.
head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32 && echoRun it three times and keep the three lines. Each line is exactly 32 characters. That length is a requirement: the Firefly III FAQ answers key errors with "Please make sure your APP_KEY is exactly 32 characters long." The comment in the Compose file says the same about STATIC_CRON_TOKEN.
Open .env with nano .env and set these lines. Leave everything else at its default for now.
APP_KEY=<first random string>
SITE_OWNER=you@example.com
TZ=Europe/Berlin
DB_PASSWORD=<third random string>
APP_URL=https://money.example.com
STATIC_CRON_TOKEN=<second random string>
TRUSTED_PROXIES=*APP_KEY is the encryption key for your sessions. The file ships with the placeholder SomeRandomStringOf32CharsExactly, so it must change. TZ ships as Europe/Amsterdam. Set your own zone, because the cron container also reads TZ from this file, so it decides when 03:00 is. The file's comment on APP_URL says it must match the external URL of your installation, so use the HTTPS name you will type in the browser. TRUSTED_PROXIES is explained in the Caddy section below. MAIL_MAILER ships as log, which means Firefly III writes mail to its log and sends nothing. Set the MAIL_* values later if you want email.
Now open .db.env with nano .db.env. The project file ships these four lines.
MYSQL_RANDOM_ROOT_PASSWORD=yes
MYSQL_USER=firefly
MYSQL_PASSWORD=<third random string>
MYSQL_DATABASE=fireflyReplace secret_firefly_password in MYSQL_PASSWORD with the same value you put in DB_PASSWORD. The two must match, because MariaDB creates the user with one and Firefly III logs in with the other. The docs say special characters such as $ may need escaping, for example as $$; quoting and interpolation context determine how to handle them. The random strings from the command above contain no $, so this does not come up.
Put Caddy in front for HTTPS
The project's Compose file publishes the app on host port 80 and the importer on host port 81, both over plain HTTP. On a public VPS you want HTTPS for the app, and you want the importer off the internet. The Firefly III docs include reverse proxy instructions for nginx and Apache; the Caddy steps here follow Caddy's own docs. Caddy serves a site over HTTPS automatically when it knows the hostname. For a public domain it needs DNS pointing at the machine and ports 80 and 443 open to the public.
Edit docker-compose.yml with nano docker-compose.yml and make these changes. Keep the YAML indentation exactly as the file has it.
- In the
appservice, delete theports:line and the- 80:8080line under it. Caddy needs port 80, and it reaches the app over thefirefly_iiinetwork instead. - In the
importerservice, change- 81:8080to- 127.0.0.1:81:8080. The importer now listens only on the server itself. - In the
importerservice, add a volume so it can read import files. The project file carries a commented-out version you can replace. - Pin the two Firefly III images: change
fireflyiii/core:latesttofireflyiii/core:version-6.7.9, andfireflyiii/data-importer:latesttofireflyiii/data-importer:version-2.3.5. The upgrade section explains why. - Add a
caddyservice, and two volumes for it.
The importer volume, indented inside the importer service:
volumes:
- ./import:/importThe new service, indented at the same level as app and db:
caddy:
image: caddy:2.11
container_name: firefly_iii_caddy
restart: always
ports:
- 80:80
- 443:443
- 443:443/udp
volumes:
- ./caddy:/etc/caddy
- caddy_data:/data
- caddy_config:/config
networks:
- firefly_iii
depends_on:
- appThen add caddy_data: and caddy_config: under the top-level volumes: key, next to firefly_iii_upload: and firefly_iii_db:, with the same indentation those two lines use. The Caddy image docs say the data directory must be persisted and must not be treated as a cache. It holds your certificates, so a named volume keeps them across restarts.
Now write the Caddyfile and create the import folder.
mkdir -p caddy import
nano caddy/Caddyfilemoney.example.com {
reverse_proxy app:8080
}app:8080 works because Caddy sits on the same Docker network, where the Firefly III container has the hostname app and listens on port 8080. Nothing outside that network can reach the app directly any more.
Why TRUSTED_PROXIES must be set behind a proxy
Firefly III now receives every request from Caddy, over plain HTTP inside the Docker network. Caddy passes on the fact that the browser used HTTPS in forwarded headers. TRUSTED_PROXIES=* tells Firefly III to accept those headers. Without it, the Firefly III FAQ says you get CSP (content security policy) errors and every link the app builds starts with http, so forms and charts break on the HTTPS page. Trusting every proxy is safe in this layout because the app has no published port, so only containers on the firefly_iii network can send it requests.
If your VPS runs ufw, open the two ports Caddy needs with sudo ufw allow 80/tcp and sudo ufw allow 443. Also check your provider's network firewall in the control panel, which is a separate control on many hosts.
Start the stack and check it
Run the start command from the docs, in the folder that holds the Compose file and the env files.
cd ~/firefly
docker compose -f docker-compose.yml up -d --pull=always
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml logs -f appps should show five containers running: app, db, importer, cron and caddy. The first start of app can take a while, because the container sets up or migrates the database when it starts. Watch the log until it settles, then press Ctrl+C to leave it.
curl -sI https://money.example.com | head -n 1The line should start with HTTP/2 and show a 2xx or 3xx status code. If curl reports a certificate error instead, Caddy has not obtained a certificate yet. Read docker compose -f docker-compose.yml logs caddy, then check that the DNS record points at this server and that ports 80 and 443 are open in both firewalls. Once it answers, open https://money.example.com in a browser and register your account.
Connect the Data Importer with a personal access token
The Data Importer is a separate application in its own container. It reads bank data or files, and it writes transactions into Firefly III through the Firefly III API (application programming interface). It can authenticate in two ways: an OAuth client ID, which you confirm in the browser each time, or a personal access token. Use the token. The importer's CLI (command-line interface) and its POST auto-import routes only work with a personal access token, so the token is what makes unattended imports possible.
Create it in Firefly III under Options, then Profile, then OAuth, then Personal Access Tokens. Copy the whole token at once. Do not use the "command line token" that the profile page also shows. The importer's env file says plainly that it is the wrong one.
Open nano .importer.env and set these lines.
FIREFLY_III_URL=http://app:8080
VANITY_URL=https://money.example.com
FIREFLY_III_ACCESS_TOKEN=<your personal access token>
IMPORT_DIR_ALLOWLIST=/import
TZ=Europe/BerlinFIREFLY_III_URL=http://app:8080 is the recommended address for this setup. The configuration guide also permits other addresses reachable from the importer container; a different address does not inherently cause a 500 | Server Error. VANITY_URL is the address the importer uses when it links you back to Firefly III, so it is your public HTTPS name. The env file's comments say to include https:// and to remove any trailing slash. IMPORT_DIR_ALLOWLIST must match the directory mounted into the container, which is /import here.
Recreate the importer so it reads the new values.
docker compose -f docker-compose.yml up -d --force-recreate importerThe importer listens only on 127.0.0.1:81, so reach it through an SSH tunnel from your own computer.
ssh -L 8081:127.0.0.1:81 you@money.example.comWhile that session is open, http://localhost:8081 in your local browser shows the importer. Keep it this way. The importer uses your token for every request it makes, so anyone who can open its page can write transactions into your books.
Set up the unattended scheduled import
The importer can run on a schedule with nobody at the keyboard. The setup has four steps: connect a data source, run one import by hand, save the configuration file it produces, and let cron replay that configuration.
First, the data source. The importer's env file has settings for several bank-data services, including GoCardless (NORDIGEN_ID and NORDIGEN_KEY), SimpleFIN (SIMPLEFIN_TOKEN) and Enable Banking (ENABLE_BANKING_APP_ID). The Firefly III docs warn that GoCardless is shifting away from Bank Account Data; their GoCardless instructions are for users who still have account access. Which service covers your bank, and what it charges, is set by that service, so check with it before you sign up. Put its credentials in .importer.env and recreate the importer as above. If you only have CSV exports from your bank, skip this step.
Second, run one import by hand in the importer's web page. At the end, download the configuration file. It is a JSON file that records every choice you made. Copy it into the import folder on the server.
scp config.json you@money.example.com:~/firefly/import/config.jsonThird, replay it once from the command line to prove it works. This is the command from the docs.
docker exec firefly_iii_importer php artisan importer:import /import/config.jsonNew transactions should appear in Firefly III. For a bank-data service the JSON file is all the command needs. For CSV or camt.053 files, the importer needs two files per import: the data file and its JSON configuration. Put pairs such as bank.csv and bank.json in ~/firefly/import and run docker exec firefly_iii_importer php artisan importer:auto-import /import instead. The docs say a CSV file without a matching JSON file is ignored.
Fourth, schedule it. Edit the crontab of a user who may run Docker (sudo crontab -e for root) and add the line from the docs, with a log file so you can see what happened.
0 22 * * * docker exec firefly_iii_importer php artisan importer:import /import/config.json >> /root/firefly-import.log 2>&1The docs line uses no -it flags, and that is correct. Cron gives the command no terminal, so -it would make it fail. Check /root/firefly-import.log the next morning.
The importer also offers an HTTP route for the same job, POST /autoimport?directory=/import&secret=.... It is off by default. You must set CAN_POST_AUTOIMPORT=true and an AUTO_IMPORT_SECRET of at least 16 characters in .importer.env, because the docs say the secret alone does not enable it. The secret travels in the URL, so it can land in logs and shell history. The docker exec route above avoids that, which is why this guide uses it.
Back up the database and the upload volume
The Firefly III backup docs list what to keep: the variables you started the containers with, especially APP_KEY, any secrets such as database passwords, and the two volumes, db and upload. They also say the Firefly III export function is not a backup.
Compose prefixes volume names with the project name, which defaults to the folder name. Find the real names first.
docker volume ls | grep firefly_iiiIn a folder called firefly you should see firefly_firefly_iii_db and firefly_firefly_iii_upload. Use the names you see. This check matters. The docs warn that if a named volume does not exist, Docker creates an empty one, the backup command archives that empty volume, and it overwrites your existing backup.
The docs back up a volume by mounting it into a temporary ubuntu container and running tar. Stop the stack first, so MariaDB is not writing files while tar reads them.
mkdir -p ~/backups/firefly
cd ~/firefly
docker compose -f docker-compose.yml stop
docker run --rm -v firefly_firefly_iii_db:/tmp -v "$HOME/backups/firefly:/backup" ubuntu tar -czvf /backup/firefly_db.tar /tmp
docker run --rm -v firefly_firefly_iii_upload:/tmp -v "$HOME/backups/firefly:/backup" ubuntu tar -czvf /backup/firefly_upload.tar /tmp
docker compose -f docker-compose.yml start
cp .env .db.env .importer.env docker-compose.yml caddy/Caddyfile ~/backups/firefly/
chmod 600 ~/backups/firefly/*tar -v lists every file it archives. A database archive that lists only /tmp/ and nothing under it means you archived an empty volume. Without the copied .env, a restored database is not enough, because the docs single out APP_KEY as the variable to keep.
If you would rather not stop the app every night, add a logical dump of the database while it runs. This is my addition, not a step from the Firefly III docs. It reads the user and password from the db container's own environment.
cd ~/firefly
docker compose exec -T db sh -c 'mariadb-dump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' > ~/backups/firefly/firefly.sql
grep -c 'CREATE TABLE' ~/backups/firefly/firefly.sqlThe grep should print a number above zero. A zero, or an empty file, means the dump failed, and the error is printed above it.
A backup on the same disk dies with the disk. Send ~/backups/firefly somewhere else, for example with encrypted off-server backups with restic. Then test a restore, because the docs are clear that an untested backup is not a backup. The docs' restore command for the database volume is below. Run it with the stack stopped.
docker run --rm -v firefly_firefly_iii_db:/recover -v "$HOME/backups/firefly:/backup" ubuntu tar -xvf /backup/firefly_db.tar -C /recover --strip 1How to upgrade Firefly III safely
The project's Compose file uses the latest tag. Docker Hub also publishes version tags: version-6.7.9, version-6.7 and version-6 for the app, and the same pattern for the importer. You pinned exact versions earlier, so an upgrade happens when you choose it, after you have read the release notes. A plain --pull=always restart keeps the selected Firefly III and importer release versions, although those image tags can be rebuilt.
The database image needs the same care. mariadb:lts follows the newest MariaDB long-term release, so a later pull can move it to a new major version. The Firefly III upgrade docs warn that a changed database image may not work with your existing data, and give MariaDB 10 against 11 as the example. Find the version you run now and pin it.
docker compose -f docker-compose.yml exec db mariadb --versionThen change image: mariadb:lts to the matching major.minor tag, for example mariadb:11.4 if that is what it prints. Never download a fresh copy of the Compose file over your edited one. It would undo every change from this guide and bring back latest.
To upgrade, take a backup, change the image tag to the new version, then run the commands from the docs.
cd ~/firefly
docker compose -f docker-compose.yml stop
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d --remove-orphans
docker compose -f docker-compose.yml logs -f appThe app migrates the database when it starts, so the first start after a pull can take a while. Firefly III can upgrade itself from very old releases, even 4.7.x. The docs still warn that some upgrades are destructive, so the backup comes first every time. Upgrade the importer the same way, by changing its tag.
When something breaks: the strings you will see
The importer shows 500 | Server Error. Check that FIREFLY_III_URL in .importer.env is reachable from the importer container. Use http://app:8080 for this setup; another reachable address, including a public address, is also valid, so the error alone does not identify the cause.
Forms do nothing and charts stay empty. The browser console shows CSP errors, and links on the page start with http://. TRUSTED_PROXIES=* is missing from .env. Set it, then run docker compose -f docker-compose.yml up -d --force-recreate app.
The app cannot connect to its database. Someone changed DB_PASSWORD or MYSQL_PASSWORD after the first start, or the two never matched. Put back the original password if you still have it. The docs' only other fix is to remove the database volume, so restore from backup afterwards.
A one-off importer container never exits. You started fireflyiii/data-importer with the latest tag. The docs say the CLI image is latest-cli, and with the wrong tag the container starts the web server as well and never exits. The docker exec route in this guide uses the running container, so it avoids this.
Recurring transactions never appear. The cron container is calling the cron endpoint with a token the app does not accept. Run docker compose -f docker-compose.yml logs cron and check that STATIC_CRON_TOKEN in .env is exactly 32 characters.
FAQ
Why do Firefly III forms and charts break behind a reverse proxy?
Firefly III builds its links from the request it receives, and behind a proxy that request arrives over plain HTTP. Set TRUSTED_PROXIES=* in .env so it accepts the forwarded headers that say the browser used HTTPS. Without it, the Firefly III FAQ says you get CSP errors and every link uses http, so forms and charts break. Recreate the app container after the change.
Can I change the Firefly III database password after the first start?
Not safely. The Firefly III docs say to change the password before the first start. If you change DB_PASSWORD in .env and MYSQL_PASSWORD in .db.env after the first run, the app loses access to the database. The documented fix is to remove the database volume, which removes your data, so back up first and keep the two values identical.
How does the Firefly III Data Importer authenticate for scheduled imports?
With a personal access token, created in Firefly III under Options, Profile, OAuth, Personal Access Tokens, and stored as FIREFLY_III_ACCESS_TOKEN in .importer.env. The CLI and the POST auto-import routes only work with a personal access token, not with an OAuth client ID. Do not use the "command line token" from the profile page, which the importer's env file calls the wrong one.
What do I need to back up for a Docker install of Firefly III?
The .env file, especially APP_KEY, the database password, and the two Docker volumes for the database and uploads. The Firefly III export function is not a backup. Check that each volume exists with docker volume ls before you archive it, because a misspelled name makes Docker create an empty volume and back that up instead.
Why does Firefly III say APP_KEY must be exactly 32 characters?
APP_KEY is the encryption key for your sessions, and the Firefly III FAQ answers key errors by telling you to make it exactly 32 characters long. Generate one with head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 32 && echo, which prints letters and digits only. The docs say to avoid special characters such as = and #.