Firefly III is a self-hosted personal finance manager for accounts, budgets, bills and recurring transactions. This guide deploys it with MariaDB, a daily cron job, Nginx and HTTPS. It also covers an optional bank-data importer and backups.
Prerequisites
| Item | Minimum | Recommended |
|---|---|---|
| VPS | 1 vCPU, 1 GB RAM, 20 GB NVMe | 2 vCPU, 2 GB RAM, 30 GB NVMe |
| OS | Ubuntu 24.04 LTS | Ubuntu 26.04 LTS |
| DNS | A record for money.example.com | AAAA record too, if IPv6 is configured |
| SMTP | Optional | Recommended for password resets and reminders |
Deploy the VPS, point DNS at its public IP and confirm dig +short money.example.com resolves. Replace the example domain and admin@example.com below. Commands assume root; run su - if needed.
Step 1: Prepare the server
ssh root@YOUR_VPS_IP
apt update && apt -y upgrade
apt -y install curl ca-certificates ufw nginx certbot python3-certbot-nginx
hostnamectl set-hostname money
timedatectl set-timezone America/New_YorkOn a 1 GB plan, add swap:
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstabAllow your actual SSH port before enabling UFW (replace OpenSSH if it is not port 22).
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw --force enable
ufw statusStep 2: Install Docker
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
docker --version
docker compose versionDocker can bypass UFW for published ports. Bind the app to 127.0.0.1 only; Nginx handles internet traffic.
Step 3: Configure Firefly III
Firefly III requires APP_KEY and STATIC_CRON_TOKEN to be exactly 32 characters. Generate them once; never change APP_KEY after first use, because it encrypts your data.
mkdir -p /opt/firefly
cd /opt/firefly
rand32() { openssl rand -hex 16; }
cat > .env <<EOF_ENV
FIREFLY_VERSION=latest
APP_ENV=production
APP_DEBUG=false
APP_URL=https://money.example.com
SITE_OWNER=admin@example.com
APP_KEY=$(rand32)
DEFAULT_LANGUAGE=en_US
TZ=America/New_York
TRUSTED_PROXIES=**
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=firefly
DB_USERNAME=firefly
DB_PASSWORD=$(rand32)
STATIC_CRON_TOKEN=$(rand32)
MAIL_MAILER=log
#MAIL_MAILER=smtp
#MAIL_HOST=smtp.example.com
#MAIL_PORT=587
#MAIL_FROM=money@example.com
#MAIL_USERNAME=money@example.com
#MAIL_PASSWORD=change-me
#MAIL_ENCRYPTION=tls
EOF_ENV
chmod 600 .env
grep -E '^(APP_KEY|STATIC_CRON_TOKEN)=' .env | awk -F= '{print $1, length($2)}'The final command should print 32 for both values. Set up SMTP to deliver real mail; MAIL_MAILER=log only writes messages to logs. Pin FIREFLY_VERSION to a tested release tag instead of latest for production.
Create /opt/firefly/docker-compose.yml:
services:
app:
image: fireflyiii/core:${FIREFLY_VERSION}
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
volumes:
- upload:/var/www/html/storage/upload
db:
image: mariadb:lts
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_DATABASE}
MARIADB_USER: ${DB_USERNAME}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
volumes:
- db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
cron:
image: alpine:3
restart: unless-stopped
depends_on:
- app
command: ["sh", "-c", "echo '0 3 * * * wget -qO- http://app:8080/api/v1/cron/${STATIC_CRON_TOKEN}' | crontab - && crond -f -L /dev/stdout"]
volumes:
upload:
db:The cron job calls Firefly III at 03:00 UTC for recurring transactions, bill warnings and exchange rates.
Step 4: Start Firefly III
cd /opt/firefly
docker compose pull
docker compose up -d
docker compose logs -f appWait for database setup, press Ctrl+C, then check locally:
curl -sI http://127.0.0.1:8080 | head -n 1
set -a; . ./.env; set +a
curl -s "http://127.0.0.1:8080/api/v1/cron/$STATIC_CRON_TOKEN" | head -c 300; echoThe app may return 200 or 302; cron returns a short job summary. Do not share your cron token.
Step 5: Configure Nginx and HTTPS
Create /etc/nginx/sites-available/firefly:
server {
listen 80;
listen [::]:80;
server_name money.example.com;
client_max_body_size 64M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}ln -s /etc/nginx/sites-available/firefly /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
certbot --nginx -d money.example.com --redirect -m admin@example.com --agree-tos -n
certbot renew --dry-runTRUSTED_PROXIES=** lets the app recognize the HTTPS proxy headers. Otherwise links can use HTTP and forms may fail.
Step 6: Create the admin account
Open https://money.example.com and register immediately. The first user becomes site owner. Configure an asset account and currency, verify Options > Administration > Configuration > Single user mode is enabled to block new registrations, and turn on two-factor authentication in Options > Profile.
Step 7 (optional): Add the Data Importer
The separate importer can process bank CSV exports and supported bank APIs. Point import.example.com at the VPS. Create a personal access token under Options > Profile > OAuth > Personal Access Tokens and put it in .env:
printf 'IMPORTER_TOKEN=%s\n' 'paste-the-long-token-here' >> /opt/firefly/.env
chmod 600 /opt/firefly/.envAdd this service under services: in the existing Compose file:
importer:
image: fireflyiii/data-importer:latest
restart: unless-stopped
depends_on:
- app
environment:
FIREFLY_III_URL: http://app:8080
VANITY_URL: https://money.example.com
FIREFLY_III_ACCESS_TOKEN: ${IMPORTER_TOKEN}
TRUSTED_PROXIES: "**"
TZ: ${TZ}
ports:
- "127.0.0.1:8081:8080"Protect the importer: its token allows anyone reaching it to write to your Firefly III account.
apt -y install apache2-utils
htpasswd -c /etc/nginx/.htpasswd-importer yournameCreate /etc/nginx/sites-available/firefly-importer:
server {
listen 80;
listen [::]:80;
server_name import.example.com;
client_max_body_size 64M;
auth_basic "Firefly Importer";
auth_basic_user_file /etc/nginx/.htpasswd-importer;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
}cd /opt/firefly && docker compose up -d
ln -s /etc/nginx/sites-available/firefly-importer /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d import.example.com --redirect -m admin@example.com --agree-tos -nBackups and restore
Back up MariaDB, receipts and .env (especially APP_KEY). Create /usr/local/bin/firefly-backup:
#!/bin/bash
set -euo pipefail
DEST=/var/backups/firefly
STAMP=$(date +%F-%H%M)
mkdir -p "$DEST"
cd /opt/firefly
docker compose exec -T db sh -c \
'mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' \
| gzip > "$DEST/db-$STAMP.sql.gz"
docker run --rm -v firefly_upload:/data -v "$DEST":/backup alpine \
tar czf "/backup/upload-$STAMP.tar.gz" -C /data .
cp .env "$DEST/env-$STAMP"
chmod 600 "$DEST"/*
find "$DEST" -type f -mtime +14 -deletechmod 700 /usr/local/bin/firefly-backup
/usr/local/bin/firefly-backup && ls -lh /var/backups/firefly
echo '30 3 * * * root /usr/local/bin/firefly-backup' > /etc/cron.d/firefly-backupCopy backups off the VPS. Restore a database dump with the original .env in place (restore its matching upload archive too for a full recovery):
cd /opt/firefly
docker compose stop app cron
gunzip -c /var/backups/firefly/db-STAMP.sql.gz | docker compose exec -T db sh -c \
'mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose start app cronUpdating Firefly III
Read release notes, back up, change FIREFLY_VERSION in .env to the chosen tag, then:
/usr/local/bin/firefly-backup
cd /opt/firefly
docker compose pull
docker compose up -d
docker compose logs -f app
docker image prune -fThe app runs database migrations on startup. Keep the OS patched with apt update && apt -y upgrade.
Troubleshooting
| Symptom | Check |
|---|---|
| 502 Bad Gateway | docker compose ps and docker compose logs app; the app may still be starting. |
SQLSTATE[HY000][2002] | Wait for database health; do not change the saved DB password. |
| Key length or cipher error | Verify APP_KEY is 32 characters before first use; never rotate it casually. |
| HTTP links or 419 form errors | Verify APP_URL uses HTTPS and TRUSTED_PROXIES=**. |
| Recurring transactions missing | Check docker compose logs cron and the local cron URL. |
| Receipts or CSV uploads fail | Increase client_max_body_size. |
| Importer cannot connect | Use FIREFLY_III_URL=http://app:8080 and a valid token. |
| No reset emails | Set up SMTP instead of the log mailer. |
