Personal Finance
    MariaDB

    Deploy Firefly III on a VPS

    Self-host Firefly III personal finance on a RamNode VPS with MariaDB, Docker Compose, daily cron jobs, Nginx HTTPS, and backups.

    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

    ItemMinimumRecommended
    VPS1 vCPU, 1 GB RAM, 20 GB NVMe2 vCPU, 2 GB RAM, 30 GB NVMe
    OSUbuntu 24.04 LTSUbuntu 26.04 LTS
    DNSA record for money.example.comAAAA record too, if IPv6 is configured
    SMTPOptionalRecommended 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

    shell
    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_York

    On a 1 GB plan, add swap:

    shell
    fallocate -l 1G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile
    swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab

    Allow your actual SSH port before enabling UFW (replace OpenSSH if it is not port 22).

    shell
    ufw allow OpenSSH
    ufw allow 'Nginx Full'
    ufw --force enable
    ufw status

    Step 2: Install Docker

    shell
    curl -fsSL https://get.docker.com | sh
    systemctl enable --now docker
    docker --version
    docker compose version

    Docker 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.

    shell
    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:

    shell
    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

    shell
    cd /opt/firefly
    docker compose pull
    docker compose up -d
    docker compose logs -f app

    Wait for database setup, press Ctrl+C, then check locally:

    shell
    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; echo

    The 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:

    shell
    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;
        }
    }
    shell
    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-run

    TRUSTED_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:

    shell
    printf 'IMPORTER_TOKEN=%s\n' 'paste-the-long-token-here' >> /opt/firefly/.env
    chmod 600 /opt/firefly/.env

    Add this service under services: in the existing Compose file:

    shell
      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.

    shell
    apt -y install apache2-utils
    htpasswd -c /etc/nginx/.htpasswd-importer yourname

    Create /etc/nginx/sites-available/firefly-importer:

    shell
    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;
        }
    }
    shell
    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 -n

    Backups and restore

    Back up MariaDB, receipts and .env (especially APP_KEY). Create /usr/local/bin/firefly-backup:

    shell
    #!/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 -delete
    shell
    chmod 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-backup

    Copy backups off the VPS. Restore a database dump with the original .env in place (restore its matching upload archive too for a full recovery):

    shell
    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 cron

    Updating Firefly III

    Read release notes, back up, change FIREFLY_VERSION in .env to the chosen tag, then:

    shell
    /usr/local/bin/firefly-backup
    cd /opt/firefly
    docker compose pull
    docker compose up -d
    docker compose logs -f app
    docker image prune -f

    The app runs database migrations on startup. Keep the OS patched with apt update && apt -y upgrade.

    Troubleshooting

    SymptomCheck
    502 Bad Gatewaydocker 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 errorVerify APP_KEY is 32 characters before first use; never rotate it casually.
    HTTP links or 419 form errorsVerify APP_URL uses HTTPS and TRUSTED_PROXIES=**.
    Recurring transactions missingCheck docker compose logs cron and the local cron URL.
    Receipts or CSV uploads failIncrease client_max_body_size.
    Importer cannot connectUse FIREFLY_III_URL=http://app:8080 and a valid token.
    No reset emailsSet up SMTP instead of the log mailer.