Portfolio Tracker
    Self-Hosted

    Deploy Ghostfolio on a VPS

    Self-host the Ghostfolio wealth and portfolio tracker on a RamNode VPS — Docker Compose, PostgreSQL, Redis, Caddy TLS, fail2ban, and nightly backups.

    Ghostfolio is an open-source wealth and portfolio management platform that aggregates positions across brokers, banks, and exchanges into a single view. It supports stocks, ETFs, bonds, commodities, crypto, and cash, and pulls market data from providers like Yahoo Finance, CoinGecko, and Manual. Self-hosting Ghostfolio gives you the analytics of a paid SaaS portfolio tracker without surrendering your holdings to a third party.

    This guide deploys Ghostfolio with PostgreSQL and Redis using Docker Compose, fronted by Caddy with TLS, hardened against brute force, and backed up nightly. We also cover initial admin configuration, data import workflows, and update procedures.

    Resource Requirements

    Ghostfolio runs a Node.js API and a Next.js frontend, plus its database and cache:

    • CPU: 2 vCPU recommended
    • RAM: 2 GB minimum, 4 GB comfortable
    • Disk: 15 GB SSD. Postgres growth is modest unless you import years of granular tick data.
    • OS: Ubuntu 24.04 LTS or Debian 12

    A RamNode plan with 2-4 GB RAM and 2 vCPU runs the full stack with headroom for backups and updates.

    Prerequisites

    • A RamNode VPS with Ubuntu 24.04 installed
    • A domain pointed at the VPS (A record on ghostfolio.example.com)
    • SSH access as a non-root sudo user

    Ghostfolio uses Yahoo Finance for market data by default, which is rate-limited but functional. For better pricing data, you can add API keys for providers like Alpha Vantage or EOD Historical Data after the initial deployment, but neither is required.

    Initial Server Hardening

    shell
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y ufw fail2ban unattended-upgrades curl gnupg ca-certificates jq
    sudo dpkg-reconfigure --priority=low unattended-upgrades

    Configure UFW:

    shell
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow 22/tcp comment 'SSH'
    sudo ufw allow 80/tcp comment 'HTTP for ACME'
    sudo ufw allow 443/tcp comment 'HTTPS'
    sudo ufw enable

    Postgres and Redis are never exposed to the public internet. Both bind to the internal Docker network only.

    Install Docker

    shell
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo usermod -aG docker $USER
    newgrp docker

    Directory Layout

    shell
    sudo mkdir -p /opt/ghostfolio
    sudo chown -R $USER:$USER /opt/ghostfolio
    cd /opt/ghostfolio

    The Compose file will manage two named Docker volumes for Postgres and Redis data. We do not bind-mount these because Postgres performance suffers on bind mounts in some Docker storage drivers, and volumes are easier to manage with docker volume commands.

    Generate Secrets

    Ghostfolio requires several cryptographic values. Generate them once and store them somewhere secure (password manager):

    shell
    echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
    echo "JWT_SECRET_KEY=$(openssl rand -hex 32)"
    echo "ACCESS_TOKEN_SALT=$(openssl rand -hex 16)"
    echo "REDIS_PASSWORD=$(openssl rand -hex 24)"

    Copy these into a .env file. Do not commit this file anywhere.

    Docker Compose Manifest

    Create /opt/ghostfolio/docker-compose.yml:

    shell
    services:
      ghostfolio:
        image: ghostfolio/ghostfolio:latest
        container_name: ghostfolio
        restart: unless-stopped
        depends_on:
          postgres:
            condition: service_healthy
          redis:
            condition: service_healthy
        environment:
          ACCESS_TOKEN_SALT: ${ACCESS_TOKEN_SALT}
          DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/ghostfolio?sslmode=prefer&connect_timeout=300
          JWT_SECRET_KEY: ${JWT_SECRET_KEY}
          POSTGRES_DB: ghostfolio
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
          REDIS_HOST: redis
          REDIS_PORT: 6379
          REDIS_PASSWORD: ${REDIS_PASSWORD}
          HOST: 0.0.0.0
          PORT: 3333
        ports:
          - "127.0.0.1:3333:3333"
        networks:
          - ghostfolio
        healthcheck:
          test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3333/api/v1/health"]
          interval: 30s
          timeout: 10s
          retries: 3
          start_period: 60s
    
      postgres:
        image: postgres:16-alpine
        container_name: ghostfolio-postgres
        restart: unless-stopped
        environment:
          POSTGRES_DB: ghostfolio
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
        volumes:
          - postgres_data:/var/lib/postgresql/data
        networks:
          - ghostfolio
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres -d ghostfolio"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      redis:
        image: redis:7-alpine
        container_name: ghostfolio-redis
        restart: unless-stopped
        command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
        volumes:
          - redis_data:/data
        networks:
          - ghostfolio
        healthcheck:
          test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
          interval: 10s
          timeout: 5s
          retries: 5
    
    volumes:
      postgres_data:
      redis_data:
    
    networks:
      ghostfolio:
        driver: bridge

    A few decisions worth noting:

    • Ghostfolio bound to localhost: Caddy is the only path in from the public internet.
    • Redis maxmemory cap at 256 MB: Ghostfolio uses Redis aggressively for market data caching. Without a cap, it will happily eat all available RAM under sustained import activity.
    • Postgres 16-alpine: Smaller image, same wire compatibility. Ghostfolio supports Postgres 13 through 16.
    • Health checks with start_period: Ghostfolio takes 30-60 seconds on cold start to run migrations. Without start_period, the container is marked unhealthy and may be killed by orchestration.

    Bring the Stack Up

    shell
    cd /opt/ghostfolio
    docker compose up -d
    docker compose logs -f ghostfolio

    Wait for Listening on http://0.0.0.0:3333 and Application is running on: http://localhost:3333. The first start runs Prisma migrations against an empty database; this takes about 30 seconds.

    Verify health:

    shell
    curl http://127.0.0.1:3333/api/v1/health
    # {"status":"ok"}

    Reverse Proxy with Caddy

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update
    sudo apt install -y caddy

    Replace /etc/caddy/Caddyfile:

    shell
    ghostfolio.example.com {
        reverse_proxy 127.0.0.1:3333 {
            transport http {
                keepalive 30s
                keepalive_idle_conns 10
            }
        }
        encode gzip zstd
        header {
            Strict-Transport-Security "max-age=31536000; includeSubDomains"
            X-Content-Type-Options "nosniff"
            X-Frame-Options "DENY"
            Referrer-Policy "strict-origin-when-cross-origin"
            Permissions-Policy "geolocation=(), microphone=(), camera=()"
            -Server
        }
        log {
            output file /var/log/caddy/ghostfolio.log
            format json
        }
    }

    Reload:

    shell
    sudo systemctl reload caddy
    sudo journalctl -u caddy -n 30

    Visit https://ghostfolio.example.com and you should see the Ghostfolio landing page.

    Initial Admin Setup

    Ghostfolio doesn't have a traditional admin signup flow. Instead, the first user you create through the UI must be designated as admin via the API, using their security token.

    Create your account in the UI: click Get started and follow the prompts. Ghostfolio uses a security token model rather than passwords; save the generated token securely.

    Once you have your user, find the user ID. Run a SQL query against Postgres:

    shell
    docker exec -it ghostfolio-postgres psql -U postgres -d ghostfolio -c "SELECT id, role, \"createdAt\" FROM \"User\" ORDER BY \"createdAt\" ASC;"

    Note the id of your account. Promote to admin:

    shell
    docker exec -it ghostfolio-postgres psql -U postgres -d ghostfolio -c "UPDATE \"User\" SET role = 'ADMIN' WHERE id = 'YOUR_USER_ID_HERE';"

    Log out and back in. You should now see the Admin Control section in the user menu.

    Restricting Signups

    By default, Ghostfolio allows anyone reaching your instance to create an account. For a personal or small-team deployment, you almost certainly want to lock this down.

    Once you have admin rights:

    1. Open Admin Control from the user menu.
    2. Find the Settings panel.
    3. Toggle Allow registration of new users to Disabled.

    After this, new accounts can only be created from the admin panel by issuing an invitation.

    Connecting Data Sources

    Ghostfolio supports several market data providers. The default Yahoo Finance source works for stocks and ETFs out of the box with no API key. Crypto pricing uses CoinGecko, also keyless.

    For better data quality on specific asset classes, configure additional providers in Admin Control > Settings > Data Providers. Common choices:

    • EOD Historical Data: Excellent for international equities. Paid, but a basic plan covers most users.
    • Alpha Vantage: Free tier with strict rate limits, suitable for low-volume queries.
    • CoinGecko Pro: Higher rate limits for crypto-heavy portfolios.

    Each provider expects an environment variable for its API key. To add Alpha Vantage:

    Add to your Compose environment:

    shell
    ALPHA_VANTAGE_API_KEY: your-api-key

    Recreate the container:

    shell
    docker compose up -d

    Importing Portfolio Data

    Ghostfolio supports CSV import for activities (buys, sells, dividends, fees) and a JSON import for full portfolio dumps. The CSV format expects columns: Date, Code (ticker), DataSource, Type (BUY/SELL/DIVIDEND/FEE/INTEREST), Currency, Unit Price, Quantity, Fee.

    A minimal CSV looks like:

    shell
    Date,Code,DataSource,Type,Currency,Unit Price,Quantity,Fee
    2024-01-15,VTI,YAHOO,BUY,USD,240.50,10,0
    2024-03-22,VTI,YAHOO,DIVIDEND,USD,0.95,10,0
    2024-07-08,VTI,YAHOO,SELL,USD,255.20,3,1.99

    Upload through Portfolio > Activities > Import. Errors typically come from ticker symbols that Yahoo doesn't recognize. Use Ghostfolio's symbol lookup in the manual Add Activity form to verify the correct Code and DataSource for your holding before bulk import.

    For broker-specific CSV formats, there are community converters on the Ghostfolio GitHub repository under ghostfolio/portfolio-converters. Worth checking before writing your own.

    Backups

    The Postgres database is the only thing you need to back up to fully restore Ghostfolio. Redis is a cache and rebuilds from market data on first query.

    Create /usr/local/sbin/ghostfolio-backup.sh:

    shell
    #!/bin/bash
    set -euo pipefail
    BACKUP_DIR="/var/backups/ghostfolio"
    TS=$(date +%Y%m%d_%H%M%S)
    mkdir -p "$BACKUP_DIR"
    
    # Dump Postgres
    docker exec ghostfolio-postgres pg_dump -U postgres -d ghostfolio -Fc \
        > "$BACKUP_DIR/ghostfolio-$TS.dump"
    
    # Also back up the .env file for the Compose stack
    cp /opt/ghostfolio/.env "$BACKUP_DIR/env-$TS.bak"
    
    # Compress and clean up
    gzip "$BACKUP_DIR/ghostfolio-$TS.dump"
    chmod 600 "$BACKUP_DIR/ghostfolio-$TS.dump.gz" "$BACKUP_DIR/env-$TS.bak"
    
    # Retention: 30 days local
    find "$BACKUP_DIR" -name 'ghostfolio-*.dump.gz' -mtime +30 -delete
    find "$BACKUP_DIR" -name 'env-*.bak' -mtime +30 -delete

    Schedule it:

    shell
    sudo chmod +x /usr/local/sbin/ghostfolio-backup.sh
    echo "0 3 * * * root /usr/local/sbin/ghostfolio-backup.sh" | sudo tee /etc/cron.d/ghostfolio-backup

    Push backups off-server. The dump file plus .env is sufficient for full disaster recovery. Use rclone to push to S3-compatible storage:

    shell
    sudo apt install -y rclone
    sudo rclone config
    # Configure your remote, then:
    echo "30 3 * * * root rclone copy /var/backups/ghostfolio s3:my-backups/ghostfolio --include='*.gz' --include='*.bak'" | sudo tee /etc/cron.d/ghostfolio-offsite

    Restoring from Backup

    To restore a Postgres dump on a fresh deployment:

    shell
    # Stop Ghostfolio so migrations don't conflict
    docker compose stop ghostfolio
    
    # Drop and recreate the database
    docker exec -it ghostfolio-postgres psql -U postgres -c "DROP DATABASE ghostfolio;"
    docker exec -it ghostfolio-postgres psql -U postgres -c "CREATE DATABASE ghostfolio;"
    
    # Restore
    gunzip -c /var/backups/ghostfolio/ghostfolio-YYYYMMDD_HHMMSS.dump.gz | \
        docker exec -i ghostfolio-postgres pg_restore -U postgres -d ghostfolio --no-owner --no-acl
    
    # Start Ghostfolio
    docker compose up -d

    Verify by logging in with your existing security token. All activities, accounts, and settings should be intact.

    Updates

    shell
    cd /opt/ghostfolio
    docker compose pull
    docker compose up -d
    docker image prune -f

    Ghostfolio runs Prisma migrations on every startup, which is normally safe. For major version bumps, read the release notes for breaking changes, especially around environment variable renames. Always run a fresh backup immediately before updating production:

    shell
    sudo /usr/local/sbin/ghostfolio-backup.sh

    If a migration fails and leaves the container in a crashloop, the logs will name the failing migration. The most common recovery is restoring the pre-update database dump.

    Pin a specific image tag in production if you want predictable update windows:

    shell
    image: ghostfolio/ghostfolio:2.130.0

    This lets you test upgrades in a staging environment before promoting.

    Monitoring

    Two signals matter most:

    • Container health: The healthcheck endpoint at /api/v1/health returns 200 when the API is up and the database is reachable. Hook this into Uptime Kuma, Healthchecks.io, or any HTTP monitor.

    • Database size: Ghostfolio's Postgres growth is modest but non-zero. Check periodically:

    shell
    docker exec ghostfolio-postgres psql -U postgres -d ghostfolio -c \
        "SELECT pg_size_pretty(pg_database_size('ghostfolio'));"

    For a personal portfolio with a few hundred activities, expect under 100 MB. Multi-tenant deployments with many users and dense activity history can reach several GB.

    • Failed login attempts: Caddy access logs in /var/log/caddy/ghostfolio.log show every request. Filter for non-200 responses to /api/v1/auth to detect brute force against the security token endpoint:
    shell
    jq 'select(.request.uri | contains("/api/v1/auth")) | select(.status != 200)' \
        /var/log/caddy/ghostfolio.log

    If you see sustained failed auth, add a fail2ban filter for that endpoint, similar to the ESPHome pattern.

    fail2ban for Auth Endpoint

    Create /etc/fail2ban/filter.d/ghostfolio-auth.conf:

    shell
    [Definition]
    failregex = ^.*"remote_ip":"<HOST>".*"uri":"/api/v1/auth.*".*"status":(401|403).*$
    ignoreregex =

    Create /etc/fail2ban/jail.d/ghostfolio.local:

    shell
    [ghostfolio-auth]
    enabled = true
    port = http,https
    filter = ghostfolio-auth
    logpath = /var/log/caddy/ghostfolio.log
    maxretry = 10
    findtime = 600
    bantime = 86400
    shell
    sudo systemctl restart fail2ban
    sudo fail2ban-client status ghostfolio-auth

    Common Issues

    • Cannot find module or migration errors on startup: Usually means the Postgres volume contains data from an incompatible Ghostfolio version. Either restore a matching backup or wipe the volume (docker volume rm ghostfolio_postgres_data) and start fresh.

    • Slow dashboard, high CPU on Node.js process: Yahoo Finance is rate-limiting your IP. Configure additional data providers, or stagger imports across smaller batches.

    • Redis OOM kill events: The maxmemory 256mb cap should prevent this, but if you removed it, large imports can blow past available RAM. Re-add the cap and restart Redis.

    • Token works but always shows empty portfolio: You logged into the wrong user. Each security token corresponds to a unique user; if you cleared cookies or used a private window, you may have inadvertently created a fresh account. The admin SQL query above lists all users and their creation dates.

    • TLS cert issuance fails: Confirm port 80 is open (Caddy needs it for ACME HTTP-01 challenges), the DNS A record has propagated, and Caddy can write to /var/lib/caddy. sudo journalctl -u caddy -n 100 will show the specific ACME error.

    This Ghostfolio deployment is now ready for daily use. Next steps to consider are setting up a secondary Postgres read replica if you build automation around the Ghostfolio API, configuring rate-limited public API access for portfolio analytics tools, and integrating with a financial data aggregator API like Plaid for automated activity sync (community feature; not in core Ghostfolio).