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
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-upgradesConfigure UFW:
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 enablePostgres and Redis are never exposed to the public internet. Both bind to the internal Docker network only.
Install Docker
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 dockerDirectory Layout
sudo mkdir -p /opt/ghostfolio
sudo chown -R $USER:$USER /opt/ghostfolio
cd /opt/ghostfolioThe 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):
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:
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: bridgeA 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
cd /opt/ghostfolio
docker compose up -d
docker compose logs -f ghostfolioWait 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:
curl http://127.0.0.1:3333/api/v1/health
# {"status":"ok"}Reverse Proxy with Caddy
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 caddyReplace /etc/caddy/Caddyfile:
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:
sudo systemctl reload caddy
sudo journalctl -u caddy -n 30Visit 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:
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:
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:
- Open
Admin Controlfrom the user menu. - Find the
Settingspanel. - Toggle
Allow registration of new userstoDisabled.
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:
ALPHA_VANTAGE_API_KEY: your-api-keyRecreate the container:
docker compose up -dImporting 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:
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.99Upload 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:
#!/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 -deleteSchedule it:
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-backupPush backups off-server. The dump file plus .env is sufficient for full disaster recovery. Use rclone to push to S3-compatible storage:
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-offsiteRestoring from Backup
To restore a Postgres dump on a fresh deployment:
# 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 -dVerify by logging in with your existing security token. All activities, accounts, and settings should be intact.
Updates
cd /opt/ghostfolio
docker compose pull
docker compose up -d
docker image prune -fGhostfolio 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:
sudo /usr/local/sbin/ghostfolio-backup.shIf 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:
image: ghostfolio/ghostfolio:2.130.0This lets you test upgrades in a staging environment before promoting.
Monitoring
Two signals matter most:
Container health: The healthcheck endpoint at
/api/v1/healthreturns 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:
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.logshow every request. Filter for non-200 responses to/api/v1/authto detect brute force against the security token endpoint:
jq 'select(.request.uri | contains("/api/v1/auth")) | select(.status != 200)' \
/var/log/caddy/ghostfolio.logIf 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:
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"uri":"/api/v1/auth.*".*"status":(401|403).*$
ignoreregex =Create /etc/fail2ban/jail.d/ghostfolio.local:
[ghostfolio-auth]
enabled = true
port = http,https
filter = ghostfolio-auth
logpath = /var/log/caddy/ghostfolio.log
maxretry = 10
findtime = 600
bantime = 86400sudo systemctl restart fail2ban
sudo fail2ban-client status ghostfolio-authCommon Issues
Cannot find moduleor 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 256mbcap 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 100will 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).
