# Can I self-host Copilot Money?

**YES** — it's called Ghostfolio. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $7.92/mo you stop paying ($95.04/yr on the Annual plan).

Ghostfolio authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/copilot-money/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Ghostfolio 3.50.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point here, and that hostname becomes `ROOT_URL` in step 3.

Say three things first. Ghostfolio tracks investments: holdings, allocation, performance,
dividends, net worth, and has no spending feed, no categories, no budgets. It connects to no
bank, so activities are typed in or imported from a CSV. Prices come from public sources, mainly
Yahoo Finance, so a blank symbol is weather, not a broken install.

It needs 2048 MB of RAM available and 10 GB free on /srv, on amd64 or arm64. Measure:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If RAM is under 2048 MB or free disk under 10 GB, print both numbers and stop. Do not install and
hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a hostname that
does not resolve.

## 2. Layout

Two owners: the PostgreSQL image chowns its own data directory on first start, so that one stays
with root.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ghostfolio /srv/ghostfolio/backups
sudo install -d -m 700 /srv/ghostfolio/postgres
ls -la /srv/ghostfolio
```

Assert: `backups` owned by the login user, `postgres` mode `drwx------` owned by root. The app
container needs no volume: every account, activity and cached price is a PostgreSQL row, and
Redis holds cache and queues that rebuild.

## 3. Secrets

Four secrets, all made on the server. `POSTGRES_PASSWORD` and `REDIS_PASSWORD` guard the data
services, `ACCESS_TOKEN_SALT` hashes the user's security token before storage, and
`JWT_SECRET_KEY` signs the session tokens. Do not print them, repeat them in your summary, or log
them. Hex rather than base64: the password goes into a connection URL.
`ROOT_URL` shares the file and is not a secret: the public hostname, `https://`, no trailing
slash, which Ghostfolio builds page-head links and its sitemap from.

```bash
umask 077
cat > /srv/ghostfolio/.env <<EOF
ROOT_URL=https://<DOMAIN>
POSTGRES_DB=ghostfolio
POSTGRES_USER=ghostfolio
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
ACCESS_TOKEN_SALT=$(openssl rand -hex 32)
JWT_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/ghostfolio/.env
umask 022
ls -l /srv/ghostfolio/.env
```

Assert: mode `-rw-------`, `ROOT_URL` reads `https://` plus the real hostname. Tell the user what
`ACCESS_TOKEN_SALT` costs: a database restored beside a different .env accepts nobody, and
changing it locks every token out, with no mail and no password to fall back on.

## 4. compose.yml

```bash
cat > /srv/ghostfolio/compose.yml <<'EOF'
# Ghostfolio · the deterministic fallback. Authored by caniselfhostit from
# upstream's own packaging at the pinned tag, read rather than copied:
#   compose file ... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/docker-compose.yml
#   variables ...... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md
#
# Ghostfolio, the PostgreSQL holding every account and cached price, and the
# Redis it caches and queues in. Upstream ships floating tags on 3333; this
# pins every image by digest and publishes 8196 on loopback, with 5432 and
# 6379 published nowhere. Digests read 2026-08-14, amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:15.19-alpine@sha256:5d23207f297fbb632e375dd80b4631282086d18f537d5e981dd0058501963a43
    container_name: ghostfolio-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ghostfolio
      POSTGRES_USER: ghostfolio
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/ghostfolio/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ghostfolio -d ghostfolio"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: ghostfolio-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Upstream's own command and probe; $$ passes a literal $ to the shell.
    command:
      - /bin/sh
      - -c
      - redis-server --requirepass "$$REDIS_PASSWORD"
    healthcheck:
      test:
        - CMD-SHELL
        - redis-cli --pass "$$REDIS_PASSWORD" ping | grep -q PONG
      interval: 10s
      retries: 12

  ghostfolio:
    image: ghostfolio/ghostfolio:3.50.0@sha256:9b8cab0eddcaecdfe1611a218f09567d39a660677b612e12837d2084d97e21a4
    container_name: ghostfolio
    restart: unless-stopped
    init: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    env_file: /srv/ghostfolio/.env
    environment:
      DATABASE_URL: postgresql://ghostfolio:${POSTGRES_PASSWORD}@postgres:5432/ghostfolio?connect_timeout=300
      REDIS_HOST: redis
      REDIS_PORT: 6379
      # Express reads the client address from the header Caddy sets.
      TRUST_PROXY: "1"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8196.
      - "127.0.0.1:8196:3333"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3333/api/v1/health"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/ghostfolio && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port. Compose reads
`${POSTGRES_PASSWORD}` from /srv/ghostfolio/.env when run from that directory.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-ghostfolio
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Ghostfolio · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# the placeholder replaced by the hostname pointed at this box. That hostname
# is ROOT_URL in .env too, and the two have to agree.

<DOMAIN> {
	# The client is an Angular bundle of several hundred kilobytes.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Nothing here is meant to be embedded anywhere.
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8196 is the loopback port compose publishes. Not open in the firewall.
	reverse_proxy 127.0.0.1:8196
}
EOF
sudo grep -c 'reverse_proxy 127.0.0.1:8196' /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert three and print each: the grep prints `1`, `caddy validate` exits 0, the reload exits 0.
The site address opening the block has to be the hostname from step 3, not the placeholder. If
validate fails, restore the copy taken above, reload, and report what it objected to.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box nothing changes:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8196 is bound to 127.0.0.1 and neither 5432 nor 6379 is published, so none has a host
port to firewall. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, nothing for 8196, 5432, 6379 or 3333.

## 7. Start and verify

First boot applies 117 Prisma migrations and a seed before the server answers. Use the loop, not
a sleep.

```bash
cd /srv/ghostfolio
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/v1/health; echo
curl -sS https://<DOMAIN>/en | grep -c '<title>Ghostfolio'
curl -sS https://<DOMAIN>/api/v1/info | grep -c createUserAccount
```

Assert all four and print what you received. The loop ends on `200`. Health prints
`{"status":"OK"}`, which upstream returns only when the database and the Redis cache both answer,
so one line covers all three containers. The third prints a number above `0`. The fourth prints
`1`, the problem this block exists to fix: account creation is open and the first account created
becomes the administrator, so right now that is whoever reaches the hostname first. If any of the
four misses, stop, run `docker compose logs --tail 40 ghostfolio` then the same for `postgres`,
and name the earlier step: a Redis or database that never reports healthy holds the app in
`depends_on`, a 502 with all three up is step 5. A running container is not success.

Close that door now. Create the first account yourself, which makes it the administrator, and
keep the credential out of the chat:

```bash
umask 077
curl -sS -X POST https://<DOMAIN>/api/v1/user -o /srv/ghostfolio/first-user.json
grep -o '"role":"[A-Z]*"' /srv/ghostfolio/first-user.json
grep -o '"accessToken":"[^"]*"' /srv/ghostfolio/first-user.json | cut -d'"' -f4 > /srv/ghostfolio/security-token.txt
chmod 600 /srv/ghostfolio/first-user.json /srv/ghostfolio/security-token.txt
wc -c /srv/ghostfolio/security-token.txt
```

Assert: `"role":"ADMIN"`, and `wc -c` prints `129`, a 128-character token plus its newline. A
`"role":"USER"` means somebody already claimed the administrator account here: stop and tell the
user, the instance is not theirs. Never print the token.

Now shut account creation off and prove it:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X PUT -H "Authorization: Bearer $(grep -o '"authToken":"[^"]*"' /srv/ghostfolio/first-user.json | cut -d'"' -f4)" -H 'Content-Type: application/json' -d '{"value":"false"}' https://<DOMAIN>/api/v1/admin/settings/IS_USER_SIGNUP_ENABLED
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/user
rm /srv/ghostfolio/first-user.json
curl -sS https://<DOMAIN>/api/v1/info | grep -c createUserAccount
```

Assert: `200`, then `403`, then `0`. The `403` is upstream refusing to create an account at all
and the `0` is the same fact from the public info endpoint; both must pass before you report
success. The `rm` runs before the last call because that token is short-lived;
security-token.txt holds the lasting credential.

STOP: tell the user to read their token with `sudo cat /srv/ghostfolio/security-token.txt`, save
it in their password manager, then open https://<DOMAIN>, press `Sign in`, and paste it.
Do not continue until they confirm they see their own empty portfolio.
That token is the only way in: the account has no email address and no password.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, activities and cached prices. The config archive
holds what rebuilds the service around it, security token included: losing that locks the user
out of an intact database.

```bash
cd /srv/ghostfolio
docker compose exec -T postgres pg_dump -U ghostfolio -d ghostfolio | gzip > /srv/ghostfolio/backups/ghostfolio-db-$(date +%F).sql.gz
sudo tar -czf /srv/ghostfolio/backups/ghostfolio-config-$(date +%F).tar.gz -C /srv/ghostfolio compose.yml .env security-token.txt -C /etc/caddy Caddyfile
ls -lh /srv/ghostfolio/backups/
```

Assert: both exist, non-empty, sizes printed. The dump is around 16 KB on a fresh install, and
nothing stops: `pg_dump` snapshots a running database consistently. A backup on the same disk is
not a backup, so run this from the user's machine, not the server:

```bash
mkdir -p ~/backups/ghostfolio
scp vps:/srv/ghostfolio/backups/* ~/backups/ghostfolio/
```

To restore: `docker compose down`, `sudo rm -rf /srv/ghostfolio/postgres`, recreate it as in
step 2, untar the config archive into /srv/ghostfolio so .env is back before anything starts,
`docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U ghostfolio -d ghostfolio`, then `docker compose up -d`.
The dump alone is not enough: the token is hashed with `ACCESS_TOKEN_SALT`, so a database beside
a fresh .env is a portfolio nobody opens.

## 9. Updating later

New versions are at https://github.com/ghostfolio/ghostfolio/releases, and the release tag is the
image tag. Upstream ships most weeks and often several times in one, so treat this pin as a
snapshot and read the changelog before crossing minor versions. PostgreSQL stays on the 15 line,
which is what upstream's compose file pins. Back up, then edit the image line:

```bash
cd /srv/ghostfolio
docker compose pull
docker compose up -d
docker compose logs --tail 30 ghostfolio
```

The entrypoint applies new migrations on the way up and the log ends with the version banner and
`Listening at http://0.0.0.0:3333`. Watch it settle, then re-run step 7's health check.

## 10. What will probably go wrong

The first boot looks like a hang. I watched `curl` return nothing for minutes while
`docker compose ps` showed the container up, and I was blaming Caddy before I read the log and
found it applying 117 Prisma migrations one line at a time. That is why step 7 loops forty times
rather than sleeping once. If it still prints `000` or `502` after ten minutes, read
`docker compose logs --tail 40 ghostfolio` first: a log printing `Applying migration` is working,
one stopped at `Can't reach database server` is step 4. The other thing that looks broken and is
not is blank prices: ask
https://<DOMAIN>/api/v1/health/data-provider/YAHOO, which answers `200` when Yahoo Finance is up
and `503` when it is rate-limiting.

## 11. Out of scope

- Do not set `ENABLE_FEATURE_AUTH_OIDC` or any `OIDC_` variable. Upstream marks that path
  experimental, it needs an identity provider the user does not have, and the token works.
- Do not add `API_KEY_` variables for paid market-data providers, and do not set
  `ENABLE_FEATURE_SUBSCRIPTION` or `STRIPE_SECRET_KEY`. The defaults need no account, and those
  switches exist for running Ghostfolio as a service for other people.
- Do not configure SMTP. Ghostfolio sends no mail, and there is no password reset to carry.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Ghostfolio 3.50.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. Ghostfolio tracks investments: holdings, allocation, performance,
dividends and net worth. It has no spending feed, no categories and no budgets, so the half of
a money app that watches where your money goes is not in here. It connects to no bank either:
activities are typed in or imported from a CSV you export yourself. And prices come from public
market-data sources, mainly Yahoo Finance, over an interface nobody promises you, so a symbol
that goes blank for a day is weather rather than a broken install.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. If RAM is short, this is
not a service to squeeze onto a 1 GB box: the application alone idles near 500 MB and the
portfolio calculations spike above that.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ghostfolio /srv/ghostfolio/backups
sudo install -d -m 700 /srv/ghostfolio/postgres
ls -la /srv/ghostfolio
```

You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. There is no directory for Ghostfolio itself, and that is correct: every
account, activity and cached price is a row in PostgreSQL, and Redis holds cache and job queues
that rebuild themselves.

## 3. Secrets

Four secrets, all generated here on the server. `POSTGRES_PASSWORD` and `REDIS_PASSWORD` guard
the two data services. `ACCESS_TOKEN_SALT` is what Ghostfolio hashes your security token with
before storing it. `JWT_SECRET_KEY` signs the session tokens your browser carries. Hex rather
than base64, because the database password goes into a connection URL where `+` and `/` would
have to be percent-encoded.

Replace `<DOMAIN>` on the first line with your real hostname before you paste.

```bash
umask 077
cat > /srv/ghostfolio/.env <<EOF
ROOT_URL=https://<DOMAIN>
POSTGRES_DB=ghostfolio
POSTGRES_USER=ghostfolio
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
ACCESS_TOKEN_SALT=$(openssl rand -hex 32)
JWT_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/ghostfolio/.env
umask 022
ls -l /srv/ghostfolio/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/ghostfolio/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all four
secrets, which is fine before the database exists and a problem afterwards: PostgreSQL keeps the
password it was created with, so a changed `POSTGRES_PASSWORD` on an existing data directory
produces an authentication failure in the Ghostfolio log rather than anything about passwords.

Do not paste that file, any of those four values, or any command output containing them into this
chat window. `ACCESS_TOKEN_SALT` deserves one more sentence: it hashes the security token step 7
gives you, so a database restored beside a different .env accepts nobody, and changing it locks
every token out at once. There is no mail here and no account password, so there is nothing to
fall back on.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/ghostfolio/compose.yml <<'EOF'
# Ghostfolio · the deterministic fallback. Authored by caniselfhostit from
# upstream's own packaging at the pinned tag, read rather than copied:
#   compose file ... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/docker-compose.yml
#   variables ...... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md
#
# Ghostfolio, the PostgreSQL holding every account and cached price, and the
# Redis it caches and queues in. Upstream ships floating tags on 3333; this
# pins every image by digest and publishes 8196 on loopback, with 5432 and
# 6379 published nowhere. Digests read 2026-08-14, amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:15.19-alpine@sha256:5d23207f297fbb632e375dd80b4631282086d18f537d5e981dd0058501963a43
    container_name: ghostfolio-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ghostfolio
      POSTGRES_USER: ghostfolio
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/ghostfolio/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ghostfolio -d ghostfolio"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: ghostfolio-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Upstream's own command and probe; $$ passes a literal $ to the shell.
    command:
      - /bin/sh
      - -c
      - redis-server --requirepass "$$REDIS_PASSWORD"
    healthcheck:
      test:
        - CMD-SHELL
        - redis-cli --pass "$$REDIS_PASSWORD" ping | grep -q PONG
      interval: 10s
      retries: 12

  ghostfolio:
    image: ghostfolio/ghostfolio:3.50.0@sha256:9b8cab0eddcaecdfe1611a218f09567d39a660677b612e12837d2084d97e21a4
    container_name: ghostfolio
    restart: unless-stopped
    init: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    env_file: /srv/ghostfolio/.env
    environment:
      DATABASE_URL: postgresql://ghostfolio:${POSTGRES_PASSWORD}@postgres:5432/ghostfolio?connect_timeout=300
      REDIS_HOST: redis
      REDIS_PORT: 6379
      # Express reads the client address from the header Caddy sets.
      TRUST_PROXY: "1"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8196.
      - "127.0.0.1:8196:3333"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3333/api/v1/health"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/ghostfolio && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/ghostfolio/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/ghostfolio/compose.yml` and paste again in one go. `DATABASE_URL` is built inside
this file rather than in .env so the password lives in one place, and compose reads
`${POSTGRES_PASSWORD}` out of /srv/ghostfolio/.env when you run it from that directory, which is
why every command below starts with `cd /srv/ghostfolio`.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-ghostfolio
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Ghostfolio · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# the placeholder replaced by the hostname pointed at this box. That hostname
# is ROOT_URL in .env too, and the two have to agree.

<DOMAIN> {
	# The client is an Angular bundle of several hundred kilobytes.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Nothing here is meant to be embedded anywhere.
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8196 is the loopback port compose publishes. Not open in the firewall.
	reverse_proxy 127.0.0.1:8196
}
EOF
sudo grep -c 'reverse_proxy 127.0.0.1:8196' /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `1` from the grep, `Valid configuration` from validate, and no output at all from
reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-ghostfolio /etc/caddy/Caddyfile`, reload,
and paste again. Check the site address on the line that opens the block: if it still reads the
literal placeholder, you pasted before replacing it, and Ghostfolio will hand out `ROOT_URL`
links pointing at a hostname Caddy does not serve.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8196`, `5432`, `6379` or `3333`.

If you do not: delete anything for those four with `sudo ufw delete allow 8196`. 8196 is bound to
127.0.0.1 by the compose file and neither 5432 nor 6379 is published at all, so none of them has
a host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and answer the
ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

The first boot applies 117 database migrations and a seed before the server answers anything, so
this takes minutes rather than seconds. The loop is the point; do not replace it with a sleep.

```bash
cd /srv/ghostfolio
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/v1/health; echo
curl -sS https://<DOMAIN>/en | grep -c '<title>Ghostfolio'
curl -sS https://<DOMAIN>/api/v1/info | grep -c createUserAccount
```

You should see, in order: the loop climbing and ending on `200`, then `{"status":"OK"}`, then a
number above `0`, then `1`.

If you do not: that health response is worth understanding, because upstream returns `OK` only
when the database and the Redis cache both answer, so one line covers all three containers. If
the loop never reaches `200`, run `docker compose logs --tail 40 ghostfolio` and read what it is
doing: a log still printing `Applying migration` is working and wants more time, and one that
stopped at `Can't reach database server` is step 3, where a changed `POSTGRES_PASSWORD` on an
existing data directory never matches. A `502` from Caddy with all three containers up is step 5.
A running container is not success.

That final `1` is the reason this step is not over. Account creation is open right now, and the
first account created on this hostname becomes the administrator, so anybody who reaches your
domain before you do owns your instance. Close it in the next two blocks.

```bash
umask 077
curl -sS -X POST https://<DOMAIN>/api/v1/user -o /srv/ghostfolio/first-user.json
grep -o '"role":"[A-Z]*"' /srv/ghostfolio/first-user.json
grep -o '"accessToken":"[^"]*"' /srv/ghostfolio/first-user.json | cut -d'"' -f4 > /srv/ghostfolio/security-token.txt
chmod 600 /srv/ghostfolio/first-user.json /srv/ghostfolio/security-token.txt
wc -c /srv/ghostfolio/security-token.txt
```

You should see: `"role":"ADMIN"`, then `129` bytes, which is a 128-character token plus its
newline.

If you do not: `"role":"USER"` means somebody already created the administrator account on this
hostname. Stop there. The instance is not yours, and the honest fix is to tear the database down
(`docker compose down`, `sudo rm -rf /srv/ghostfolio/postgres`, recreate it as in step 2) and
start step 7 again with the hostname already resolving. Do not paste the contents of either file
into this chat window.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X PUT -H "Authorization: Bearer $(grep -o '"authToken":"[^"]*"' /srv/ghostfolio/first-user.json | cut -d'"' -f4)" -H 'Content-Type: application/json' -d '{"value":"false"}' https://<DOMAIN>/api/v1/admin/settings/IS_USER_SIGNUP_ENABLED
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/user
rm /srv/ghostfolio/first-user.json
curl -sS https://<DOMAIN>/api/v1/info | grep -c createUserAccount
```

You should see: `200`, then `403`, then `0`.

If you do not: a `401` on the first line means the session token in first-user.json was not read
correctly, so run the first command again exactly as written. A `201` on the second line means
account creation is still open and the setting did not take, and you should not go any further
until it prints `403`. The `403` is upstream refusing to create an account at all, the `0` is the
same fact from the public info endpoint, and both together are the proof that your instance is
now yours alone. The `rm` drops the short-lived session token, which is why it runs before the
last call; the lasting credential is in security-token.txt.

Read your security token once, put it in your password manager, then sign in:

```bash
sudo cat /srv/ghostfolio/security-token.txt
```

You should see: one long line of hex. Open https://<DOMAIN>, press `Sign in` in the header, paste
that token, and you are looking at your own empty portfolio.

If you do not: there is no password reset and no email address on this account, so that token is
the only way in. Do not paste it into this chat window. If you have lost it before signing in
once, the fastest recovery is to delete the database as described above and repeat step 7.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, activities and cached prices. The config archive
holds what rebuilds the service around it, including the security token, because losing that
locks you out of a database that is otherwise intact.

```bash
cd /srv/ghostfolio
docker compose exec -T postgres pg_dump -U ghostfolio -d ghostfolio | gzip > /srv/ghostfolio/backups/ghostfolio-db-$(date +%F).sql.gz
sudo tar -czf /srv/ghostfolio/backups/ghostfolio-config-$(date +%F).tar.gz -C /srv/ghostfolio compose.yml .env security-token.txt -C /etc/caddy Caddyfile
ls -lh /srv/ghostfolio/backups/
```

You should see: two files, the dump around 16 KB on a fresh install and the config archive a
couple of kilobytes. Nothing goes offline: `pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/ghostfolio
scp vps:/srv/ghostfolio/backups/* ~/backups/ghostfolio/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/ghostfolio/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty portfolio:

```bash
cd /srv/ghostfolio
docker compose down
sudo rm -rf /srv/ghostfolio/postgres
sudo install -d -m 700 /srv/ghostfolio/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/ghostfolio/backups/ghostfolio-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U ghostfolio -d ghostfolio
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/user
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `403` from the last command,
which means the closed-signup setting came back with the database rather than resetting to open.

If you do not: `role "ghostfolio" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this: the dump alone is not enough. Your token is stored hashed with `ACCESS_TOKEN_SALT`
from .env, so a database restored beside a freshly generated .env is a portfolio nobody can open.
The two files travel together or neither is worth anything.

## 9. Updating later

New versions are listed at https://github.com/ghostfolio/ghostfolio/releases, and the release tag
is the image tag, so release `3.51.0` is image tag `3.51.0`. Upstream ships most weeks and often
several times in one week, so treat this pin as a snapshot rather than a resting place, and read
the changelog before crossing several minor versions at once. PostgreSQL stays on the 15 line
because that is what upstream's own compose file pins; moving it is a database upgrade, not an
image bump. Take both backup artifacts first, then edit the `image:` line in
/srv/ghostfolio/compose.yml to the new tag and its digest.

```bash
cd /srv/ghostfolio
docker compose pull
docker compose up -d
docker compose logs --tail 30 ghostfolio
```

You should see: migration output, then the version banner, then `Listening at
http://0.0.0.0:3333`, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, because a container that starts can
still be failing if a migration stopped halfway.

## 10. What will probably go wrong

The first boot looks like a hang. I watched `curl` return nothing for minutes while
`docker compose ps` showed the container up, and I was blaming Caddy before I read the log and
found it applying 117 Prisma migrations one line at a time. That is why step 7 loops forty times
rather than sleeping once. The other thing that looks broken and is not is blank prices: ask
https://<DOMAIN>/api/v1/health/data-provider/YAHOO, which answers `200` when Yahoo Finance is up
and `503` when it is rate-limiting. Nothing in this install can fix that second one. It is the
cost of getting market data from a source that never promised you any.

## 11. Out of scope

- Do not set `ENABLE_FEATURE_AUTH_OIDC` or any `OIDC_` variable. Upstream marks that path
  experimental, it needs an identity provider you do not have, and the token already works.
- Do not add `API_KEY_` variables for paid market-data providers, and do not set
  `ENABLE_FEATURE_SUBSCRIPTION` or `STRIPE_SECRET_KEY`. The defaults need no account, and those
  switches exist for running Ghostfolio as a service for other people.
- Do not configure SMTP. Ghostfolio sends no mail here, and there is no password reset to carry.
- Do not publish 3333, 5432 or 6379 on the host. Caddy is the only way in.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Ghostfolio 3.50.0, with its PostgreSQL and Redis, under ~/selfhost/ghostfolio, answering
at http://localhost:8196.

## 1. Preflight

Say this before step 2 runs. Ghostfolio tracks investments, not spending: no categories, no
budgets, no bank connection, so activities are typed in or imported from a CSV. It answers at
http://localhost:8196 only, so a phone cannot open it, and prices refresh only when this computer
is awake.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This stack needs 2048 MB of RAM available
and 10 GB free on the home disk, on amd64 or arm64. If RAM is under 2048 MB or free disk under
10 GB, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/ghostfolio/backups
ls -la ~/selfhost/ghostfolio
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: every account and
price is a PostgreSQL row in the volume step 5 creates.

## 4. Secrets

Four secrets. `POSTGRES_PASSWORD` and `REDIS_PASSWORD` guard the data services,
`ACCESS_TOKEN_SALT` hashes the user's security token before storage, `JWT_SECRET_KEY` signs the
session tokens. Generate all four here, print none, keep them out of summaries and logs.

```bash
umask 077
cat > ~/selfhost/ghostfolio/.env <<EOF
ROOT_URL=http://localhost:8196
POSTGRES_DB=ghostfolio
POSTGRES_USER=ghostfolio
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
ACCESS_TOKEN_SALT=$(openssl rand -hex 32)
JWT_SECRET_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/ghostfolio/.env
umask 022
ls -l ~/selfhost/ghostfolio/.env
```

Assert: mode `-rw-------`. Tell the user what `ACCESS_TOKEN_SALT` costs: a database restored
beside a different .env accepts
nobody, and changing it locks every token out, with no password reset to fall back on. On Windows
those bits are advisory and the real boundary is the Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/ghostfolio/compose.yml <<'EOF'
# Ghostfolio · the deterministic fallback for the local path. Authored by
# caniselfhostit from upstream's own packaging at the pinned tag:
#   compose file ... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/docker-compose.yml
#   variables ...... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/ghostfolio/, so one file works on macOS, Linux and Windows. The
# database is a named volume, not a bind mount: PostgreSQL chowns its data
# directory to its own uid, which Windows bind mounts cannot allow. 5432 and
# 6379 are published nowhere. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:15.19-alpine@sha256:5d23207f297fbb632e375dd80b4631282086d18f537d5e981dd0058501963a43
    container_name: ghostfolio-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ghostfolio
      POSTGRES_USER: ghostfolio
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ghostfolio-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ghostfolio -d ghostfolio"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: ghostfolio-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Upstream's own command and probe. $$ becomes a literal $ inside.
    command:
      - /bin/sh
      - -c
      - redis-server --requirepass "$$REDIS_PASSWORD"
    healthcheck:
      test:
        - CMD-SHELL
        - redis-cli --pass "$$REDIS_PASSWORD" ping | grep -q PONG
      interval: 10s
      retries: 12

  ghostfolio:
    image: ghostfolio/ghostfolio:3.50.0@sha256:9b8cab0eddcaecdfe1611a218f09567d39a660677b612e12837d2084d97e21a4
    container_name: ghostfolio
    restart: unless-stopped
    init: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    env_file: ./.env
    environment:
      DATABASE_URL: postgresql://ghostfolio:${POSTGRES_PASSWORD}@postgres:5432/ghostfolio?connect_timeout=300
      REDIS_HOST: redis
      REDIS_PORT: 6379
    ports:
      - "127.0.0.1:8196:3333"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3333/api/v1/health"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  ghostfolio-pgdata:
EOF
cd ~/selfhost/ghostfolio && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one port, one volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. A certificate attests a public name and
nothing here has one, and browsers treat http://localhost as a secure context, so pages needing
crypto still work. Nothing is published beyond loopback: 8196 is bound to 127.0.0.1, not the
user's phone, not a laptop on the wifi, nobody outside. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/ghostfolio/compose.yml
```

Assert: that prints `1`. PostgreSQL and Redis publish no host port, so neither 5432 nor 6379 can
appear.

## 7. Start and verify

First boot applies 117 database migrations and a seed before the server answers. Use the loop,
not a sleep.

```bash
cd ~/selfhost/ghostfolio
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8196/api/v1/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8196/api/v1/health; echo
curl -sS http://localhost:8196/en | grep -c '<title>Ghostfolio'
curl -sS http://localhost:8196/api/v1/info | grep -c createUserAccount
```

Assert all four and print what you received. The loop ends on `200`. Health prints
`{"status":"OK"}`, which upstream returns only when the database and the Redis cache both answer,
so one line covers all three containers. The third prints a number above `0`. The fourth prints
`1`: account creation is open and the first account created becomes the administrator. If any of
the four misses, read `docker compose logs --tail 40 ghostfolio`: `Applying migration` wants
more time, `Can't reach database server` is step 4, `port is already allocated` is step 10.
A running container is not success.

Create the administrator account yourself and close the door behind it. An instance that takes
new accounts is one router change from belonging to somebody else:

```bash
umask 077
curl -sS -X POST http://localhost:8196/api/v1/user -o ~/selfhost/ghostfolio/first-user.json
grep -o '"role":"[A-Z]*"' ~/selfhost/ghostfolio/first-user.json
grep -o '"accessToken":"[^"]*"' ~/selfhost/ghostfolio/first-user.json | cut -d'"' -f4 > ~/selfhost/ghostfolio/security-token.txt
chmod 600 ~/selfhost/ghostfolio/first-user.json ~/selfhost/ghostfolio/security-token.txt
curl -sS -o /dev/null -w '%{http_code}\n' -X PUT -H "Authorization: Bearer $(grep -o '"authToken":"[^"]*"' ~/selfhost/ghostfolio/first-user.json | cut -d'"' -f4)" -H 'Content-Type: application/json' -d '{"value":"false"}' http://localhost:8196/api/v1/admin/settings/IS_USER_SIGNUP_ENABLED
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8196/api/v1/user
rm ~/selfhost/ghostfolio/first-user.json
curl -sS http://localhost:8196/api/v1/info | grep -c createUserAccount
```

Assert, in order: `"role":"ADMIN"`, `200`, `403`, `0`. The `403` is upstream refusing to create
an account, the `0` is the same fact from the public info endpoint, and both must pass. Never
print the token, and do not continue on `"role":"USER"`.

STOP: tell the user to read their token with `cat ~/selfhost/ghostfolio/security-token.txt`, save
it in their password manager, then open http://localhost:8196, press `Sign in`, and paste it.
Do not continue until they confirm they see their own empty portfolio.
That token is the only way in: the account has no email and no password.

## 8. First backup and restore

Two artifacts: a database dump, and a config archive with what rebuilds the service around it,
token included.

```bash
cd ~/selfhost/ghostfolio
docker compose exec -T postgres pg_dump -U ghostfolio -d ghostfolio | gzip > ~/selfhost/ghostfolio/backups/ghostfolio-db-$(date +%F).sql.gz
tar -C ~/selfhost/ghostfolio -czf ~/selfhost/ghostfolio/backups/ghostfolio-config-$(date +%F).tar.gz compose.yml .env security-token.txt
ls -lh ~/selfhost/ghostfolio/backups/
```

Assert: both exist, non-empty, sizes printed. The dump is about 16 KB fresh.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination off this computer, a sync folder or a USB stick, and
copy both there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert: the user confirms
both filenames are there, or say there is no backup.

To restore, in this order: untar the config archive into ~/selfhost/ghostfolio first, so
compose.yml and .env are back before any container starts, because PostgreSQL takes
`POSTGRES_PASSWORD` from .env the moment it initialises an empty volume. Then
`docker compose down -v`, the one place `-v` belongs, `docker compose up -d postgres`, wait 30
seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U ghostfolio -d ghostfolio`, then `docker compose up -d`.
The dump alone is not enough: the token is hashed with `ACCESS_TOKEN_SALT`, so a database beside
a fresh .env is unopenable.

## 9. Updating later

New versions are at https://github.com/ghostfolio/ghostfolio/releases, and the release tag is the
image tag. Upstream ships most weeks, so treat this pin as a snapshot and read the changelog
before crossing minor versions. PostgreSQL stays on upstream's 15 line. Back up, then edit the
image line:

```bash
cd ~/selfhost/ghostfolio
docker compose pull
docker compose up -d
docker compose logs --tail 30 ghostfolio
```

The log ends with the version banner and `Listening at http://0.0.0.0:3333`. Watch it settle,
then re-run step 7's health check.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8196, and got a connection error that reads like
a lost database. It was not: Docker Desktop had not started with the session, so nothing was
listening on 8196, and `restart: unless-stopped` acts only once the Docker daemon is up. Turn on
its start-at-login setting, then after a reboot run
`cd ~/selfhost/ghostfolio && docker compose up -d` before deciding anything is broken. Two more
look broken and are not: `port is already allocated` means something else holds 8196, which
`lsof -nP -iTCP:8196 -sTCP:LISTEN` names, and blank prices are usually the machine having been
asleep. For prices ask http://localhost:8196/api/v1/health/data-provider/YAHOO: `200` means Yahoo
answered and `503` means rate-limited, neither of which this install fixes.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8196 to 0.0.0.0 so a phone can reach it, and do not point `ROOT_URL` at a LAN
  address. Both put a portfolio on every network the user joins.
- Do not set `ENABLE_FEATURE_AUTH_OIDC`, any `OIDC_` or `API_KEY_` variable, or
  `ENABLE_FEATURE_SUBSCRIPTION`. The defaults need no account.
````

## docker-compose.yml

```yaml
# Ghostfolio · the deterministic fallback. Authored by caniselfhostit from
# upstream's own packaging at the pinned tag, read rather than copied:
#   compose file ... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/docker-compose.yml
#   variables ...... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md
#
# Ghostfolio, the PostgreSQL holding every account and cached price, and the
# Redis it caches and queues in. Upstream ships floating tags on 3333; this
# pins every image by digest and publishes 8196 on loopback, with 5432 and
# 6379 published nowhere. Digests read 2026-08-14, amd64+arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:15.19-alpine@sha256:5d23207f297fbb632e375dd80b4631282086d18f537d5e981dd0058501963a43
    container_name: ghostfolio-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ghostfolio
      POSTGRES_USER: ghostfolio
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/ghostfolio/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ghostfolio -d ghostfolio"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: ghostfolio-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Upstream's own command and probe; $$ passes a literal $ to the shell.
    command:
      - /bin/sh
      - -c
      - redis-server --requirepass "$$REDIS_PASSWORD"
    healthcheck:
      test:
        - CMD-SHELL
        - redis-cli --pass "$$REDIS_PASSWORD" ping | grep -q PONG
      interval: 10s
      retries: 12

  ghostfolio:
    image: ghostfolio/ghostfolio:3.50.0@sha256:9b8cab0eddcaecdfe1611a218f09567d39a660677b612e12837d2084d97e21a4
    container_name: ghostfolio
    restart: unless-stopped
    init: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    env_file: /srv/ghostfolio/.env
    environment:
      DATABASE_URL: postgresql://ghostfolio:${POSTGRES_PASSWORD}@postgres:5432/ghostfolio?connect_timeout=300
      REDIS_HOST: redis
      REDIS_PORT: 6379
      # Express reads the client address from the header Caddy sets.
      TRUST_PROXY: "1"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8196.
      - "127.0.0.1:8196:3333"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3333/api/v1/health"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Ghostfolio · the deterministic fallback for the local path. Authored by
# caniselfhostit from upstream's own packaging at the pinned tag:
#   compose file ... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/docker-compose.yml
#   variables ...... https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/ghostfolio/, so one file works on macOS, Linux and Windows. The
# database is a named volume, not a bind mount: PostgreSQL chowns its data
# directory to its own uid, which Windows bind mounts cannot allow. 5432 and
# 6379 are published nowhere. Digests read 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:15.19-alpine@sha256:5d23207f297fbb632e375dd80b4631282086d18f537d5e981dd0058501963a43
    container_name: ghostfolio-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ghostfolio
      POSTGRES_USER: ghostfolio
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ghostfolio-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ghostfolio -d ghostfolio"]
      interval: 10s
      retries: 12

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: ghostfolio-redis
    restart: unless-stopped
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # Upstream's own command and probe. $$ becomes a literal $ inside.
    command:
      - /bin/sh
      - -c
      - redis-server --requirepass "$$REDIS_PASSWORD"
    healthcheck:
      test:
        - CMD-SHELL
        - redis-cli --pass "$$REDIS_PASSWORD" ping | grep -q PONG
      interval: 10s
      retries: 12

  ghostfolio:
    image: ghostfolio/ghostfolio:3.50.0@sha256:9b8cab0eddcaecdfe1611a218f09567d39a660677b612e12837d2084d97e21a4
    container_name: ghostfolio
    restart: unless-stopped
    init: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    env_file: ./.env
    environment:
      DATABASE_URL: postgresql://ghostfolio:${POSTGRES_PASSWORD}@postgres:5432/ghostfolio?connect_timeout=300
      REDIS_HOST: redis
      REDIS_PORT: 6379
    ports:
      - "127.0.0.1:8196:3333"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3333/api/v1/health"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  ghostfolio-pgdata:
```

## Caddyfile

```text
# Ghostfolio · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# the placeholder replaced by the hostname pointed at this box. That hostname
# is ROOT_URL in .env too, and the two have to agree.

<DOMAIN> {
	# The client is an Angular bundle of several hundred kilobytes.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Nothing here is meant to be embedded anywhere.
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8196 is the loopback port compose publishes. Not open in the firewall.
	reverse_proxy 127.0.0.1:8196
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Ghostfolio · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=portfolio.example.com ./install.sh
#
# Authored by caniselfhostit from upstream's own packaging at tag 3.50.0:
#   https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/docker-compose.yml
#   https://github.com/ghostfolio/ghostfolio/blob/3.50.0/README.md
#   https://github.com/ghostfolio/ghostfolio/blob/3.50.0/docker/entrypoint.sh
#
# Four secrets are generated here, on this machine: the PostgreSQL password,
# the Redis password, ACCESS_TOKEN_SALT and JWT_SECRET_KEY. All four go into
# /srv/ghostfolio/.env with mode 600 and none is ever printed.
#
# This script also creates the first Ghostfolio account, which upstream gives
# the ADMIN role, then turns account creation off and proves it is off. The
# security token for that account is written to /srv/ghostfolio/security-token.txt
# with mode 600. It is the only way in: the account has no email address and
# no password, and nothing here can reset it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/ghostfolio}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. portfolio.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; this stack wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# The PostgreSQL image chowns its own data directory on first start, so that
# one stays owned by root. Ghostfolio itself needs no volume: every account,
# activity and cached price is a row in PostgreSQL, and Redis holds cache and
# job queues that rebuild themselves.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Hex rather than base64 for all four: the database password is pasted into a
# connection URL, where + and / would have to be percent-encoded. Read them
# later with
#   sudo grep -E 'POSTGRES_PASSWORD|REDIS_PASSWORD|ACCESS_TOKEN_SALT|JWT_SECRET_KEY' /srv/ghostfolio/.env
#
# ACCESS_TOKEN_SALT is what hashes the security token below. A database
# restored beside a different .env accepts nobody.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		ROOT_URL=https://${DOMAIN_HOST}
		POSTGRES_DB=ghostfolio
		POSTGRES_USER=ghostfolio
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		REDIS_PASSWORD=$(openssl rand -hex 32)
		ACCESS_TOKEN_SALT=$(openssl rand -hex 32)
		JWT_SECRET_KEY=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-ghostfolio"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8196, 5432 and 6379 are not among them ----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8196, 5432 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The entrypoint applies 117 Prisma migrations and a seed before the server
# answers anything, so the first boot takes minutes.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/v1/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/api/v1/health answered ${code:-nothing}. Check: docker compose logs --tail 40 ghostfolio"

curl -sS "https://${DOMAIN_HOST}/api/v1/health" | grep -q '"status":"OK"' \
	|| die "health answered 200 without status OK. Check: docker compose logs --tail 40 ghostfolio"

curl -sS "https://${DOMAIN_HOST}/en" | grep -q '<title>Ghostfolio' \
	|| die "the client did not render. Check: docker compose logs --tail 40 ghostfolio"

# --- 7. Claim the instance, then close it ------------------------------------
#
# Account creation is open on a fresh install and the first account created
# gets the ADMIN role, so this runs immediately after the server answers.

umask 077
curl -sS -X POST "https://${DOMAIN_HOST}/api/v1/user" -o "$APP_DIR/first-user.json"
grep -q '"role":"ADMIN"' "$APP_DIR/first-user.json" \
	|| die "the new account is not ADMIN, so somebody else claimed this hostname first. Stop and investigate."
grep -o '"accessToken":"[^"]*"' "$APP_DIR/first-user.json" | cut -d'"' -f4 > "$APP_DIR/security-token.txt"
chmod 600 "$APP_DIR/first-user.json" "$APP_DIR/security-token.txt"
umask 022
[ "$(wc -c < "$APP_DIR/security-token.txt")" -eq 129 ] || die "the security token is not 128 characters. Stop and investigate."

put="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \
	-H "Authorization: Bearer $(grep -o '"authToken":"[^"]*"' "$APP_DIR/first-user.json" | cut -d'"' -f4)" \
	-H 'Content-Type: application/json' -d '{"value":"false"}' \
	"https://${DOMAIN_HOST}/api/v1/admin/settings/IS_USER_SIGNUP_ENABLED" || true)"
[ "$put" = "200" ] || die "disabling account creation returned ${put}, not 200. Stop and investigate."

signup="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/v1/user" || true)"
[ "$signup" = "403" ] || die "account creation still answers ${signup}, not 403. The instance is open. Stop."

advertised="$(curl -sS "https://${DOMAIN_HOST}/api/v1/info" | grep -c createUserAccount || true)"
[ "$advertised" = "0" ] || die "the info endpoint still advertises createUserAccount. The instance is open. Stop."

rm -f "$APP_DIR/first-user.json"

# --- 8. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U ghostfolio -d ghostfolio | gzip > "$APP_DIR/backups/ghostfolio-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/ghostfolio-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env security-token.txt -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/ghostfolio-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Ghostfolio is answering at https://${DOMAIN_HOST}

	  1. Your security token is in $APP_DIR/security-token.txt, mode 600.
	     Read it with
	       sudo cat $APP_DIR/security-token.txt
	     and put it in your password manager now. It was not printed here.
	     Open https://${DOMAIN_HOST}, press Sign in, and paste it. That token
	     is the only way in: this account has no email address, no password,
	     and no reset.
	  2. Account creation was open for the few seconds between start-up and
	     step 7. It is closed now: an unauthenticated POST to /api/v1/user
	     answers 403 and /api/v1/info no longer advertises createUserAccount.
	  3. Market prices come from public sources, mainly Yahoo Finance. If
	     holdings show no value, ask
	       https://${DOMAIN_HOST}/api/v1/health/data-provider/YAHOO
	     200 means the source answered, 503 means it is rate-limiting. Neither
	     is something this install can fix.
	  4. First backup written to $APP_DIR/backups: a database dump and a
	     config archive holding compose.yml, .env, the security token and the
	     live Caddyfile. They travel together, because the token is stored
	     hashed with ACCESS_TOKEN_SALT from .env. They are also on the same
	     disk as the data, which is not a backup. Copy them off tonight.

DONE
```

## Also evaluated

Ranked below Ghostfolio for this swap. The prompts above install Ghostfolio only.

- **Firefly III** — A full transaction ledger for household money, with budgets, rules and reports, and imports you drive yourself. Second, and first if what you want back is the ledger rather than the portfolio. Firefly III is a full household transaction record: accounts with running balances, searchable transactions, rules that categorise on the way in, recurring entries, budgets and reports. It is the closest thing here to the shape of a whole personal-finance app, and it has the same missing piece as everything else on this page, because it logs into nothing. Transactions arrive as CSV files or through the separate Data Importer on a second hostname wired to a paid data provider.
- **Actual Budget** — Zero-based envelope budgeting in one container, with the budget file on your disk and no subscription attached to your money. Third, and the one to pick if the reason you opened Copilot every week was the budget. Actual is envelope budgeting done properly, one container, a local-first file that syncs through a server you run, and an install that is over before your coffee cools. It gives up the investment tracking entirely and the reporting depth of Firefly III, and it has the same bank-feed gap, reached through GoCardless or SimpleFIN and paid for separately. Pick it when the method is the point.

The page this mirrors: https://caniselfhostit.com/self-host/copilot-money/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
