What the stack runs
Ghostfolio is a TypeScript application: a NestJS backend and an Angular frontend in one image, with PostgreSQL (through Prisma) as the database and Redis as the cache. Official images are published on Docker Hub for linux/amd64, linux/arm/v7 and linux/arm64. The compose file in the repository wires three services together:
| Service | Image | Holds | Reachable on |
|---|---|---|---|
| ghostfolio | ghostfolio/ghostfolio:latest | nothing persistent | 3333/tcp (all addresses as shipped) |
| postgres | postgres:15-alpine | all accounts, activities, market data | compose network only |
| redis | redis:alpine | cache, password from REDIS_PASSWORD | compose network only |
The only named volume is the PostgreSQL data directory, which is why one database dump plus the .env file is a complete backup. Every service reads the same .env, which sits one directory above the compose file (env_file: ../.env). Its required keys, checked against the README’s variable table: ACCESS_TOKEN_SALT and JWT_SECRET_KEY (random strings), DATABASE_URL, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, REDIS_HOST, REDIS_PORT and REDIS_PASSWORD. REDIS_DB (default 0) and REDIS_TLS (default false) are optional.
Choosing a line
The project publishes no CPU or memory figures, so this page gives none. Our reading: for one household the load is small, and what matters more is a disk you trust on a machine nobody else runs code on, since the database is a full record of your holdings.
- Moonshot cartridges suit a box that does this one job. Console access differs by cartridge: m510 and m710x boards include one, while other cartridges give power on and off only.
- Single-socket rack servers each have an out-of-band management controller, so a locked-out SSH session can be recovered through the browser console, and leave room for other services beside Ghostfolio.
- Not the X-Gene ARM cartridge. Its operating system choice stops at Ubuntu 18.04, older than the packages below, even though an arm64 image exists.
Each line, its memory range and its cities are on the dedicated server range. Expect a racked machine about 30 minutes after the order’s approval; built-to-order lines take 4 to 24 hours. Racked machines waiting now are listed separately. IPv6 can be added at checkout without charge; the ufw rules here handle both families.
Install: firewall, compose file, secrets
The README’s route is: clone the repository, copy .env.example to .env, fill it in, run docker compose -f docker/docker-compose.yml up -d. Only two files matter to a server that pulls the image from Docker Hub, so the block below fetches the compose file and writes .env with the same keys as .env.example. Three departures are ours: we fetch only the compose file instead of cloning the repository, secrets are generated as hex (nothing in DATABASE_URL then needs escaping), and the port mapping is narrowed to loopback. The packages used need Ubuntu 24.04 or later; 22.04 has no caddy package.
Why loopback: a mapping with no address, like the official 3333:3333, is published on every host address (0.0.0.0 and [::]). On IPv4 that traffic skips ufw’s rules; on IPv6 it does too when Docker’s own IPv6 networking is enabled, without that, IPv6 connections land on docker-proxy, a host process ufw can filter. Binding to 127.0.0.1 keeps it local; that is strictly local-only on Docker Engine 28 or newer (moby#45610), so check docker version.
# Ubuntu 24.04 or later, as root. Firewall first: IPv6 rules on, SSH allowed, then on
apt update && apt install -y docker.io docker-compose-v2 ufw openssl curl
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
# The two files the stack needs (our layout: /opt/ghostfolio)
install -d -m 755 /opt/ghostfolio/docker
cd /opt/ghostfolio
curl -fsSL -o docker/docker-compose.yml \
https://raw.githubusercontent.com/ghostfolio/ghostfolio/main/docker/docker-compose.yml
# Our change: publish the app on loopback only
sed -i 's/- 3333:3333/- 127.0.0.1:3333:3333/' docker/docker-compose.yml
# Must print bind-ok; otherwise edit the ports line by hand before going on
grep -q -- '- 127.0.0.1:3333:3333' docker/docker-compose.yml && echo bind-ok
# .env is created at mode 600 before any secret goes in
install -m 600 /dev/null .env
{
echo COMPOSE_PROJECT_NAME=ghostfolio
echo REDIS_HOST=redis
echo REDIS_PORT=6379
echo "REDIS_PASSWORD=$(openssl rand -hex 24)"
echo POSTGRES_DB=ghostfolio-db
echo POSTGRES_USER=user
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
echo "ACCESS_TOKEN_SALT=$(openssl rand -hex 32)"
echo 'DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300'
echo "JWT_SECRET_KEY=$(openssl rand -hex 32)"
} > .env
docker compose -f /opt/ghostfolio/docker/docker-compose.yml up -d
docker compose -f /opt/ghostfolio/docker/docker-compose.yml ps
The secrets come from openssl inside the shell, so they never appear on a command line or in shell history. The app waits for the database and Redis health checks; ps should show three healthy services. Compose commands here carry the full -f path, so they work from any directory.
The first account is the admin
The README’s setup step is blunt: the first user created through Get Started gets the role ADMIN. The source shows why: createUser in user.service.ts gives a new account the ADMIN role whenever no admin exists yet. Create it over a tunnel through your own login, from your workstation:
- Run
ssh -L 3333:127.0.0.1:3333 you@your-server. On a first connection SSH asks “Are you sure you want to continue connecting (yes/no/[fingerprint])?”: compare the fingerprint, then type yes. - Open
http://localhost:3333and press Get Started. The dialog warns that a lost security token cannot be recovered. - Tick “I understand that if I lose my security token, I cannot recover my account” and press Continue. That press creates the account; only then does an ADMIN badge appear beside the dialog title.
- The token appears next. Press Copy to clipboard straight away and store it in a password manager: the dialog says it is only visible once. Create Account, enabled by the copy, only closes the dialog and signs you in.
- Open Admin Control, stay on Overview and turn the User Signup toggle off.
Three points from the source code rather than the docs. Sign-up is on until an admin turns it off (property.service.ts). With it off, signupUser in user.controller.ts refuses new accounts with HTTP 403. The stored token is an HMAC keyed with ACCESS_TOKEN_SALT (createAccessToken in user.service.ts, compared in auth.service.ts), so a changed salt means existing security tokens no longer sign in: keep the .env you started with. The toggle’s place comes from admin-overview.html and the dialog wording from user-account-registration-dialog.html. That checkbox gains a Terms of Service clause only when ENABLE_FEATURE_SUBSCRIPTION is on, which is off by default (register-page.component.ts, configuration.service.ts), so a self-hosted install shows none.
Market data providers and API keys
Ghostfolio prices holdings through data providers. Its self-hosting FAQ names four as officially supported: YAHOO, COINGECKO, MANUAL, and GHOSTFOLIO, the last one available with a Ghostfolio Premium subscription through an API key. Every other provider is considered experimental.
- CoinGecko: optional
API_KEY_COINGECKO_DEMOorAPI_KEY_COINGECKO_PROin.env(README), then recreate the app container. - GHOSTFOLIO: the key is entered under Admin Control, Settings, with Set API key (location from
admin-settings.component.html). - MANUAL: for assets no provider covers, the FAQ points to the MANUAL source with a scraper configuration, or a custom asset profile created in Market Data.
- Exchange rates are yours to gather: Admin Control, Market Data, filter by currencies, open the pair (USDEUR, say) and run Gather Historical Market Data.
HTTPS on the host with Caddy
The project ships no proxy configuration, so this is our own setup: Caddy from the Ubuntu archive, running on the host and forwarding to the loopback port. Order matters. The DNS record comes first, because Caddy asks for the certificate as soon as it reads the name. Then the certificate. Ghostfolio’s own settings change last.
# 1. DNS first: the name must already resolve to this server
getent ahosts portfolio.example.com
# 2. Caddy on the host, web ports open on IPv4 and IPv6, certificate issued
apt install -y caddy
ufw allow 80,443/tcp
cat > /etc/caddy/Caddyfile <<'EOF'
portfolio.example.com {
reverse_proxy 127.0.0.1:3333
}
EOF
systemctl reload caddy
curl -sI https://portfolio.example.com | head -n 1
# 3. Then tell Ghostfolio its public address and that one proxy sits in front
printf '%s\n' 'ROOT_URL=https://portfolio.example.com' 'TRUST_PROXY=1' >> /opt/ghostfolio/.env
docker compose -f /opt/ghostfolio/docker/docker-compose.yml up -d --force-recreate ghostfolio
Create the A record, and AAAA if you took IPv6, before running it, and replace the example name. Caddy here is an ordinary host process, so without the ufw allow line it is blocked on IPv4 and IPv6 alike. ROOT_URL and TRUST_PROXY come from the README’s variable table, which gives 1 for a single proxy and leaves Strict-Transport-Security to the proxy. If you would rather manage several sites through a web UI, see Nginx Proxy Manager on a dedicated server.
Backups and a live restore
The self-hosting FAQ suggests regular full database dumps. A dump alone is not enough to rebuild: the database password, Redis password and token salt live in .env. The script below archives all three pieces.
# /opt/ghostfolio/backup.sh -- our script, not the project's; run as root
set -euo pipefail
umask 077
out=/var/backups/ghostfolio
install -d -m 700 "$out"
day=$(date +%F)
dc="docker compose -f /opt/ghostfolio/docker/docker-compose.yml"
$dc exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
| gzip > "$out/db-$day.sql.gz"
gzip -t "$out/db-$day.sql.gz"
cp /opt/ghostfolio/.env "$out/env-$day"
cp /opt/ghostfolio/docker/docker-compose.yml "$out/compose-$day.yml"
find "$out" -type f -mtime +14 -name '*-20*' -delete
Schedule it with a file /etc/cron.d/ghostfolio-backup holding one line: 30 2 * * * root umask 077; install -d -m 700 /var/backups/ghostfolio; bash /opt/ghostfolio/backup.sh > /var/backups/ghostfolio/run-$(date +\%F).log 2>&1. The backups contain every secret, so copy them to storage you control, off this machine.
To roll back on the same server, our procedure keeps the current database until the restored one works. dc stands for docker compose -f /opt/ghostfolio/docker/docker-compose.yml; set it with dc="docker compose -f /opt/ghostfolio/docker/docker-compose.yml".
- Check the archive:
f=/var/backups/ghostfolio/db-2026-10-22.sql.gz; test -s "$f" && gzip -t "$f" && echo archive-ok. - Stop the app only:
$dc stop ghostfolio. - Rename the live database:
$dc exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d postgres -c "ALTER DATABASE \"$POSTGRES_DB\" RENAME TO ghostfolio_old"'. - Confirm the renamed copy exists before going on:
$dc exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d postgres -Atc "SELECT datname FROM pg_database"'must list ghostfolio_old. - Create an empty database and load the dump:
$dc exec -T postgres sh -c 'createdb -U "$POSTGRES_USER" -O "$POSTGRES_USER" "$POSTGRES_DB"', thengunzip -c "$f" | $dc exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'. - Start and check:
$dc start ghostfolio, then repeat$dc psuntil ghostfolio shows healthy (the README notes migrations run before the server starts). Only thencurl -fsS http://127.0.0.1:3333/api/v1/healthshould print a status of OK; sign in with your token. - Only then drop the old copy:
$dc exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE ghostfolio_old"'.
On a new server (with dc set as above), put the saved .env (mode 600) and compose file in place before the database container ever starts: the postgres image creates its user and password from that file only on first start. Run $dc up -d postgres redis, load the dump into the empty database it created, then $dc up -d.
Upgrades, licence and Premium
With the latest tag, the README’s upgrade is $dc pull then $dc up -d; to pin instead, edit the image version in the compose file. The README says the container applies any database schema migrations itself on startup, so take a dump first and keep it until the new version has been checked.
Ghostfolio is licensed under the GNU Affero General Public License v3 (AGPL-3.0), a copyleft licence. Our general reading, not legal advice: running it unchanged on your own server carries no duty. Distributing copies, modified or not, means passing the source on with them. Serving a modified build to other users over a network means offering those users its source.
Ghostfolio Premium is the project’s own fully managed cloud offering. Its FAQ describes it as the open-source code base with extras such as the markets overview and a professional data provider, and the README says the revenue covers hosting, data providers and continued development. For budgeting rather than holdings, see Firefly III on a dedicated server.
Questions
Does Ghostfolio use passwords?
Not by default. Each account signs in with a security token generated when the account is created, and the creation dialog shows it only once. ENABLE_FEATURE_AUTH_TOKEN controls this and defaults to true; OpenID Connect is available as an experimental option.
Can several people share one instance?
Yes. While sign-up is open, everyone creates a separate account with a separate token, and the first account stays the admin. Our suggestion: open sign-up briefly, let them register, then switch User Signup off again.
Is Redis data worth backing up?
No. The README describes Redis as the cache, and the compose file gives it no volume. Everything you entered lives in PostgreSQL, so the dump plus the .env file is what a rebuild needs.
Which Ghostfolio version does the compose file run?
It uses the latest tag of ghostfolio/ghostfolio from Docker Hub, so each pull fetches the newest release. Edit the tag in the compose file to stay on one version and move deliberately.
Will it run on an ARM server?
The images are built for linux/arm64 and linux/arm/v7 as well as amd64. On our range the X-Gene ARM cartridge is the exception, because Ubuntu 18.04 is the newest release it boots.
Can I bring in transactions I already recorded elsewhere?
Yes. Import and export of transactions are listed among the features, and the README documents an import endpoint that takes activities as JSON with a bearer token. Export works the same way, which also gives you a portable copy beside the database dump.