# Can I self-host Canny?

**YES, BUT** — it's called Fider. ONE WEEKEND setup · ~3.5 hours to running · 1 GB RAM minimum · $99/mo you stop paying ($1,188/yr on the Pro plan) — a metered rate, not a whole bill.

Fider authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/canny/

## 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 Fider v0.36.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 at this server. Say this when you ask: the hostname becomes
`BASE_URL`, Fider builds every sign-in link it mails out of that value, and changing it later
invalidates links people already hold.

Fider needs 1024 MB of RAM available and 5 GB free on /srv. Both images publish amd64 and arm64.
Measure four things:

```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 available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop.

Settle one thing more, because step 3 stops dead without it. Fider has no passwords: you sign in
by following a link it mails you, and the container refuses to boot until it is told where to
post mail. Upstream states that without a valid SMTP server you get
`panic: could not find environment variable named 'EMAIL_SMTP_HOST'`. Tell the user to have a
host, port, username and password from a transactional mail provider in front of them, and an
address for outgoing mail.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root. The PostgreSQL image chowns its own data directory on first start, so leave it alone.
There is no uploads folder: `BLOB_STORAGE` defaults to `sql`, so logos and post images are rows
too.

## 3. Secrets

Two secrets: the PostgreSQL password and the token-signing key. Generate both on the server. Do
not print either, do not repeat them in your summary, and do not put them in any log line. Hex
for both: one rides inside a connection string where escaping would bite, and 64 bytes of it
clears the 512 bits upstream's own secret generator recommends for `JWT_SECRET`.

```bash
umask 077
cat > /srv/fider/.env <<EOF
BASE_URL=https://<DOMAIN>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 64)
SIGNUP_DISABLED=false
EMAIL_NOREPLY=CHANGE_ME
EMAIL_SMTP_HOST=CHANGE_ME
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USERNAME=CHANGE_ME
EMAIL_SMTP_PASSWORD=CHANGE_ME
EOF
chmod 600 /srv/fider/.env
umask 022
ls -l /srv/fider/.env
```

Assert: the file exists with mode `-rw-------`. `JWT_SECRET` signs every session and sign-in
link, so rotating it signs everybody out. `SIGNUP_DISABLED` is false only until step 7 closes
it: while it is false, whoever reaches the hostname first can claim this board.

STOP: tell the user to open `nano /srv/fider/.env`, replace every `CHANGE_ME` with the matching
value, correct `EMAIL_SMTP_PORT` if their relay is not 587, add a line reading
`EMAIL_SMTP_ENABLE_IMPLICIT_TLS=true` if it is 465, and save. Do not continue until they
confirm, and never ask them to paste those values to you.

```bash
grep -c CHANGE_ME /srv/fider/.env || true
```

Assert: that prints `0`. It counts lines, never values.

## 4. compose.yml

```bash
cat > /srv/fider/compose.yml <<'EOF'
# Fider · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker hosting ... https://docs.fider.io/hosting-instance
#   configuration .... https://github.com/getfider/fider/blob/v0.36.0/app/pkg/env/env.go
#   health route ..... https://github.com/getfider/fider/blob/v0.36.0/app/cmd/routes.go
#   image entrypoint . https://github.com/getfider/fider/blob/v0.36.0/Dockerfile
#
# Two services: Fider and the PostgreSQL that holds every post, vote, comment,
# account and uploaded image, because BLOB_STORAGE defaults to sql. Upstream
# requires PostgreSQL 12 or newer; this pins 16. Upstream's guide runs
# getfider/fider:stable, a tag that moves under you, so this pins the newest
# version tag the registry carries, v0.36.0, by digest. LOG_SQL is off:
# upstream defaults it to true, inserting every log line into a logs table
# nothing reads or trims. `fider migrate` runs before the server listens, which
# the health check's start period allows for. Digests read on 2026-08-07; both
# images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: fider-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: fider
      POSTGRES_USER: fider
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/fider/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fider -d fider"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  fider:
    image: getfider/fider:v0.36.0@sha256:466669b3c932158d7fc082d4037ad881fce6c5cd49cf973e15d9bcaedc27889a
    container_name: fider
    restart: unless-stopped
    env_file: /srv/fider/.env
    environment:
      DATABASE_URL: postgres://fider:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable
      # Upstream's default is true, which writes every log line into a logs
      # table nothing reads. The console log is unaffected.
      LOG_SQL: "false"
    healthcheck:
      # The image ships `fider ping`, which asks its own /_health route.
      test: ["CMD", "./fider", "ping"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    ports:
      - "127.0.0.1:8181:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/fider && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No password sits in that file: compose reads
`${POSTGRES_PASSWORD}` out of /srv/fider/.env.

## 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-fider
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Fider · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.fider.io/hosting-instance,
# https://docs.fider.io/how-to-enable-ssl and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, and Fider builds every sign-in link it mails out of
# BASE_URL, so the two have to agree exactly.

<DOMAIN> {
	# Fider already sets Content-Security-Policy, X-Content-Type-Options and
	# Referrer-Policy itself, so this adds only the two it does not: HSTS,
	# because the way in is a link that arrives by mail, and a framing rule,
	# because Fider ships no embeddable widget.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}

	encode zstd gzip

	# 8181 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8181
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-fider, reload, and report what it objected to. Caddy asks for the
certificate on the first request, renews it itself, and sets X-Forwarded-Proto, which is how
Fider knows the request arrived over https.

## 6. Firewall

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

```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. 8181 stays closed because compose binds it to 127.0.0.1, 5432 because compose never
publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8181 or 5432.

## 7. Start and verify

The image runs `fider migrate` before the server listens, so the first boot takes minutes.

```bash
cd /srv/fider
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>/_health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/_health
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/signup | grep -c 'Sign up for Fider and let your customers share'
```

Assert all four, and print what you received for each. The loop ends printing `200`. The health
body is exactly `{"status":"Healthy"}`, Fider answering after a database ping. The bare hostname
prints `307`, because no board exists yet and Fider redirects to the installer. The grep prints
`1`. If any of the four misses, stop, run `docker compose logs --tail 60 fider` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a
`panic: could not find environment variable named` line points at step 3, where a `CHANGE_ME`
survived; a database that never reports healthy points at step 2; a `502` while the loop still
runs means migrations are going. A running container is not success.

The first screen at https://<DOMAIN>/signup is the installer, headed `1. Who are you?` above a
name and email box, with `2. What is this Feedback Forum for?` below.

STOP: tell the user to open https://<DOMAIN>/signup, fill in their name, their email and the
name of the board, submit it, then follow the link in the confirmation mail Fider sends. Do not
continue until they confirm the board has opened. That mail is the only proof the relay from
step 3 works, and until the link is followed every page says `Pending Activation`. If nothing
lands within two minutes, read step 10 first.

Once they confirm, close the installer and prove it is closed:

```bash
sed -i 's/^SIGNUP_DISABLED=false$/SIGNUP_DISABLED=true/' /srv/fider/.env
cd /srv/fider
docker compose up -d --force-recreate fider
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/_health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signup
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert: the loop reaches `200` again, /signup prints `404` and the bare hostname prints `200`.
That pair is the security assert here. Both must pass before you report success.

## 8. First backup and restore

Two artifacts. The database holds every post, vote, comment, account and image. The config
archive rebuilds the service around it.

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

Assert: both files exist and are non-empty. Print both sizes. Nothing is stopped: `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:

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

To restore: `docker compose down`, `sudo rm -rf /srv/fider/postgres`, recreate that directory as
in step 2, untar the config archive into /srv/fider 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 fider -d fider`, then `docker compose up -d`. Tell the
user why the archive matters as much as the dump: `JWT_SECRET` lives in .env, every session and
sign-in link is signed with it, and a database restored beside a new key logs everybody out.

## 9. Updating later

New versions are listed at https://github.com/getfider/fider/releases, and the tags that exist
as images at https://hub.docker.com/r/getfider/fider/tags. Check the second list too: on
2026-08-07 the newest release was v0.36.1 and the registry carried no image under that name,
which is why this pins v0.36.0. Do not answer that with the `stable` tag upstream's guide uses;
it moves without telling you. Take both backups first, then edit the image line in
/srv/fider/compose.yml to the new tag and its digest:

```bash
cd /srv/fider
docker compose pull
docker compose up -d
docker compose logs --tail 40 fider
```

Fider migrates its database on the way up, so watch that log until it settles, then re-run the
`/_health` check from step 7 before calling the update done.

## 10. What will probably go wrong

The confirmation mail. I filled in the installer, the page turned into `Pending Activation`, and
I sat there for ten minutes with a healthy container, a `200` from `/_health` and an empty inbox,
because Fider had handed the message to the relay and the relay had refused it out of sight. The
board is half-created at that point: not empty, not usable, and reloading fixes nothing. Read
`docker compose logs --tail 60 fider` first, then the spam folder. Mine was a from-address the
relay had not verified, so `EMAIL_NOREPLY` had to change. Upstream documents the reset if you
must start over: `TRUNCATE TABLE tenants RESTART IDENTITY CASCADE;` piped through
`docker compose exec -T postgres psql -U fider -d fider`, which deletes the board and everything
on it. Safe on the day you install, and never again.

## 11. Out of scope

- Do not configure Google, Facebook, GitHub or any other OAuth sign-in. Each is an app
  registered in somebody else's console, and this install signs people in by email.
- Do not set `SSL_AUTO`, `SSL_CERT` or `SSL_CERT_KEY`. Caddy terminates TLS here, and upstream's
  own certificate integration requires that Fider not sit behind a proxy.
- Do not switch `BLOB_STORAGE` to `s3` or `fs`. The database default keeps the whole install in
  one dump; an object store is a second thing to back up and to secure.
- Do not set `HOST_MODE` to multi-tenant. One board per install is what this prompt asserts
  against, and multi-tenant wants a wildcard certificate.
````

## 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 Fider v0.36.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. Fider has no passwords. You sign in by following a link it mails you,
and the container will not start at all until it is told where to post mail, so this install
needs an SMTP relay from a transactional mail provider before it needs anything else. Have the
host, port, username, password and a from-address in front of you. `<DOMAIN>` also becomes
`BASE_URL`, which Fider prints inside every link it sends, so pick the hostname you intend to
keep.

## 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 `1024` MB available, at least `5` 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. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname while the certificate is issued.

## 2. Layout

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

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 uploads directory here and that is not an omission:
`BLOB_STORAGE` defaults to `sql`, so logos and images uploaded to a post are rows in the
database like everything else.

## 3. Secrets

Two secrets: the PostgreSQL password and the token-signing key. Both are generated here, on the
server, and both go straight into a file only you can read. Hex for both, because one rides
inside a connection string where escaping would bite, and 64 bytes of it clears the 512 bits
upstream's own secret generator recommends for `JWT_SECRET`.

```bash
umask 077
cat > /srv/fider/.env <<EOF
BASE_URL=https://<DOMAIN>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 64)
SIGNUP_DISABLED=false
EMAIL_NOREPLY=CHANGE_ME
EMAIL_SMTP_HOST=CHANGE_ME
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USERNAME=CHANGE_ME
EMAIL_SMTP_PASSWORD=CHANGE_ME
EOF
chmod 600 /srv/fider/.env
umask 022
ls -l /srv/fider/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

Now open `nano /srv/fider/.env` and replace every `CHANGE_ME` with the matching value from your
relay. Correct `EMAIL_SMTP_PORT` if your relay is not 587, and add a line reading
`EMAIL_SMTP_ENABLE_IMPLICIT_TLS=true` if it is 465. Then count what is left:

```bash
grep -c CHANGE_ME /srv/fider/.env || true
```

You should see: `0`. That command counts lines, never values.

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/fider/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten both
secrets, which is fine before the database exists and a problem afterwards: PostgreSQL keeps the
password it was created with, so a changed one on an existing volume shows up as an
authentication failure in the Fider log rather than as anything about passwords.

Do not paste that file, either secret, your relay password, or any output containing them into
this chat window. The agent path never sees those values, and this one will hand them to a third
party unless you keep them out.

## 4. compose.yml

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

```bash
cat > /srv/fider/compose.yml <<'EOF'
# Fider · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker hosting ... https://docs.fider.io/hosting-instance
#   configuration .... https://github.com/getfider/fider/blob/v0.36.0/app/pkg/env/env.go
#   health route ..... https://github.com/getfider/fider/blob/v0.36.0/app/cmd/routes.go
#   image entrypoint . https://github.com/getfider/fider/blob/v0.36.0/Dockerfile
#
# Two services: Fider and the PostgreSQL that holds every post, vote, comment,
# account and uploaded image, because BLOB_STORAGE defaults to sql. Upstream
# requires PostgreSQL 12 or newer; this pins 16. Upstream's guide runs
# getfider/fider:stable, a tag that moves under you, so this pins the newest
# version tag the registry carries, v0.36.0, by digest. LOG_SQL is off:
# upstream defaults it to true, inserting every log line into a logs table
# nothing reads or trims. `fider migrate` runs before the server listens, which
# the health check's start period allows for. Digests read on 2026-08-07; both
# images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: fider-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: fider
      POSTGRES_USER: fider
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/fider/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fider -d fider"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  fider:
    image: getfider/fider:v0.36.0@sha256:466669b3c932158d7fc082d4037ad881fce6c5cd49cf973e15d9bcaedc27889a
    container_name: fider
    restart: unless-stopped
    env_file: /srv/fider/.env
    environment:
      DATABASE_URL: postgres://fider:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable
      # Upstream's default is true, which writes every log line into a logs
      # table nothing reads. The console log is unaffected.
      LOG_SQL: "false"
    healthcheck:
      # The image ships `fider ping`, which asks its own /_health route.
      test: ["CMD", "./fider", "ping"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    ports:
      - "127.0.0.1:8181:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/fider && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/fider/.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/fider/compose.yml` and paste again in one go. No password appears in that file:
compose reads `${POSTGRES_PASSWORD}` out of /srv/fider/.env to build the connection string.

## 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-fider
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Fider · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.fider.io/hosting-instance,
# https://docs.fider.io/how-to-enable-ssl and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, and Fider builds every sign-in link it mails out of
# BASE_URL, so the two have to agree exactly.

<DOMAIN> {
	# Fider already sets Content-Security-Policy, X-Content-Type-Options and
	# Referrer-Policy itself, so this adds only the two it does not: HSTS,
	# because the way in is a link that arrives by mail, and a framing rule,
	# because Fider ships no embeddable widget.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}

	encode zstd gzip

	# 8181 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8181
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

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

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-fider /etc/caddy/Caddyfile`, reload, and
paste again. Caddy asks for the certificate on the first request and renews it itself, and it
sets X-Forwarded-Proto, which is how Fider knows the request arrived over https even though it
speaks plain http on 8181.

## 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 `8181` or `5432`.

If you do not: delete anything for `8181` or `5432` with `sudo ufw delete allow 8181`. 8181 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to 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 further.

## 7. Start and verify

The image runs `fider migrate` before the server listens, so the first boot takes minutes.

```bash
cd /srv/fider
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>/_health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/_health
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/signup | grep -c 'Sign up for Fider and let your customers share'
```

You should see, in order: the loop reaching `200`, then exactly `{"status":"Healthy"}`, then
`307`, then `1`.

If you do not: the `307` is the one worth understanding. No board exists yet, so Fider redirects
the bare hostname to its installer, and seeing that redirect is good news rather than a
misconfiguration. A `panic: could not find environment variable named` line in
`docker compose logs --tail 60 fider` points straight back at step 3, where a `CHANGE_ME`
survived. If the loop never reaches `200`, run `docker compose logs --tail 20 postgres` first,
because a database that never reports healthy is step 2 done wrong. A running container is not
success.

The first screen at https://<DOMAIN>/signup is the installer, headed `1. Who are you?` above a
name and email box, with `2. What is this Feedback Forum for?` below it. Open it in a browser,
fill in your name, your email address and the name of the board, and submit. Fider then mails
you a confirmation link and shows `Pending Activation` on every page until you follow it. That
mail arriving is the only proof your relay works; if nothing lands within two minutes, read step
10 before touching anything.

Once the board has opened, close the installer and prove it is closed:

```bash
sed -i 's/^SIGNUP_DISABLED=false$/SIGNUP_DISABLED=true/' /srv/fider/.env
cd /srv/fider
docker compose up -d --force-recreate fider
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/_health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signup
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: the loop reaching `200` again, then `404` for /signup, then `200` for the bare
hostname.

If you do not: a `307` from /signup instead of `404` means the `sed` did not match, so check
that the line in .env reads `SIGNUP_DISABLED=true` and recreate the container again. This pair
is the security assert of the whole install. Left open on a public hostname, the installer is a
board anyone who finds your address can claim.

## 8. First backup and restore

Two artifacts. The database holds every post, vote, comment, account and image. The config
archive rebuilds the service around it.

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

You should see: two files, both a few kilobytes on a fresh install. 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/fider
scp vps:/srv/fider/backups/* ~/backups/fider/
```

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

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 alias Prompt Zero created lives.

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

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

You should see: `CREATE TABLE` and `COPY` lines from psql, then `200`, which means the board
survived a database that was deleted and rebuilt.

If you do not: `role "fider" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand why the config archive
matters as much as the dump: `JWT_SECRET` lives in .env, every session and every unexpired
sign-in link is signed with it, and a database restored beside a freshly generated key logs
everybody out at once.

## 9. Updating later

New versions are listed at https://github.com/getfider/fider/releases, and the tags that exist
as images at https://hub.docker.com/r/getfider/fider/tags. Check the second list too: on
2026-08-07 the newest release was v0.36.1 and the registry carried no image under that name,
which is why this pins v0.36.0. Do not answer that with the `stable` tag upstream's guide uses;
it moves without telling you. Take both backups first, then edit the `image:` line in
/srv/fider/compose.yml to the new tag and its digest.

```bash
cd /srv/fider
docker compose pull
docker compose up -d
docker compose logs --tail 40 fider
```

You should see: migration output, then the server starting, 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, and open the board as well,
because a service that answers `Healthy` can still be failing to render if a migration stopped
halfway.

## 10. What will probably go wrong

The confirmation mail. I filled in the installer, the page turned into `Pending Activation`, and
I sat there for ten minutes with a healthy container, a `200` from `/_health` and an empty inbox,
because Fider had handed the message to the relay and the relay had refused it out of sight. The
board is half-created at that point: not empty, not usable, and reloading fixes nothing. Read
`docker compose logs --tail 60 fider` first, then the spam folder. Mine was a from-address the
relay had not verified, so `EMAIL_NOREPLY` had to change. Upstream documents the reset if you
must start over: `TRUNCATE TABLE tenants RESTART IDENTITY CASCADE;` piped through
`docker compose exec -T postgres psql -U fider -d fider`, which deletes the board and everything
on it. Safe on the day you install, and never again.

## 11. Out of scope

- Do not configure Google, Facebook, GitHub or any other OAuth sign-in. Each is an app
  registered in somebody else's console, and this install signs people in by email.
- Do not set `SSL_AUTO`, `SSL_CERT` or `SSL_CERT_KEY`. Caddy terminates TLS here, and upstream's
  own certificate integration requires that Fider not sit behind a proxy.
- Do not switch `BLOB_STORAGE` to `s3` or `fs`. The database default keeps the whole install in
  one dump; an object store is a second thing to back up and to secure.
- Do not set `HOST_MODE` to multi-tenant. One board per install is what these steps assert
  against, and multi-tenant wants a wildcard certificate.
````

## 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 Fider v0.36.0, with the PostgreSQL it stores posts and votes in, under ~/selfhost/fider,
answering at http://localhost:8181.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Fider's product is a board customers post to and vote on, and this one answers only at
http://localhost:8181, which means "this computer" wherever it is read. Nobody whose feedback
they wanted can open it. They get a private list of their own ideas with a vote button.

Detect the OS and measure the machine:

```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 install needs 1024 MB of RAM available
and 5 GB free on the home disk, and both images publish amd64 and arm64. On macOS and Windows
that figure is the host's, and Docker Desktop takes its cut. If either floor is missed, print
both numbers and stop.

Settle one thing more, because step 4 stops dead without it. Fider has no passwords: you sign in
by following a link it mails you, and it refuses to boot until told where to post mail. Upstream
states that without a valid SMTP server you get
`panic: could not find environment variable named 'EMAIL_SMTP_HOST'`. Have the user find a relay
host, port, username, password and from-address first.

## 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/fider/backups
ls -la ~/selfhost/fider
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: posts, votes,
comments, accounts and images are rows in PostgreSQL, which step 5 keeps in a volume Docker
manages, so no ownership fix is needed anywhere.

## 4. Secrets

Two secrets: the PostgreSQL password and the token-signing key. Generate both here, print
neither, keep both out of your summary and any log line. Hex for both, 64 bytes of it for
`JWT_SECRET`, which clears the 512 bits upstream's generator recommends.

```bash
cd ~/selfhost/fider
umask 077
cat > .env <<EOF
BASE_URL=http://localhost:8181
POSTGRES_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 64)
SIGNUP_DISABLED=false
EMAIL_NOREPLY=CHANGE_ME
EMAIL_SMTP_HOST=CHANGE_ME
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USERNAME=CHANGE_ME
EMAIL_SMTP_PASSWORD=CHANGE_ME
EOF
chmod 600 .env
umask 022
ls -l .env
```

Assert: mode `-rw-------`. Git Bash ships openssl; on Windows the mode bits are advisory and the
real boundary is the user's own account. `SIGNUP_DISABLED` is false until step 7 closes it.

STOP: tell the user to open ~/selfhost/fider/.env in an editor, replace every `CHANGE_ME`,
correct `EMAIL_SMTP_PORT` if their relay is not 587, add a line reading
`EMAIL_SMTP_ENABLE_IMPLICIT_TLS=true` if it is 465, and save. Do not continue until they
confirm, and never ask them to paste those values.

```bash
grep -c CHANGE_ME .env || true
```

Assert: that prints `0`. It counts lines, never values.

## 5. compose.yml

```bash
cat > ~/selfhost/fider/compose.yml <<'EOF'
# Fider · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker hosting ... https://docs.fider.io/hosting-instance
#   configuration .... https://github.com/getfider/fider/blob/v0.36.0/app/pkg/env/env.go
#
# Two services, paths relative to ~/selfhost/fider/ so one file works on
# macOS, Linux and Windows. The database is a named volume, not a bind mount:
# the PostgreSQL image chowns its data directory to a uid Docker Desktop
# cannot grant on a Windows home directory. Upstream wants PostgreSQL 12 or
# newer and runs the floating getfider/fider:stable; this pins 16, and v0.36.0
# by digest, the newest version tag the registry carries. BLOB_STORAGE
# defaults to sql, so images are rows too, and LOG_SQL is off because
# upstream's default writes every log line into a table nothing reads.
# Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: fider-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: fider
      POSTGRES_USER: fider
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - fider-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fider -d fider"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  fider:
    image: getfider/fider:v0.36.0@sha256:466669b3c932158d7fc082d4037ad881fce6c5cd49cf973e15d9bcaedc27889a
    container_name: fider
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgres://fider:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable
      LOG_SQL: "false"
    healthcheck:
      # `fider ping` ships in the image and asks its own /_health route.
      test: ["CMD", "./fider", "ping"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    ports:
      - "127.0.0.1:8181:3000"
    depends_on:
      postgres:
        condition: service_healthy

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

Assert: that prints `compose OK`. Two services, one published port, one named volume, no
password: compose reads `${POSTGRES_PASSWORD}` out of ./.env.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve, and a certificate attests a public name nothing here has; browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work. Nothing is
published beyond loopback: 8181 is bound to 127.0.0.1, this computer only, not the user's phone
and not anyone whose feedback they wanted. Confirm:

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

Assert: that prints `1`, the published-port line `- "127.0.0.1:8181:3000"`. PostgreSQL publishes
no host port, so 5432 cannot appear.

## 7. Start and verify

The image runs `fider migrate` before the server listens, so the first boot takes minutes.

```bash
cd ~/selfhost/fider
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:8181/_health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8181/_health
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8181/
curl -sS http://localhost:8181/signup | grep -c 'Sign up for Fider and let your customers share'
```

Assert all four, and print what you received for each. The loop ends on `200`. The health body
is exactly `{"status":"Healthy"}`. The bare address prints `307`, because no board exists yet and
Fider redirects to the installer. The grep prints `1`. If any misses, stop, run
`docker compose logs --tail 60 fider`, and name the cause: a
`panic: could not find environment variable named` line points at step 4, where a `CHANGE_ME`
survived, and `port is already allocated` means something else holds 8181. A running container
is not success.

The first screen at http://localhost:8181/signup is the installer, headed `1. Who are you?`
above a name and email box, with `2. What is this Feedback Forum for?` below.

STOP: tell the user to open http://localhost:8181/signup, fill in their name, their email and
the board's name, submit, then follow the link in the confirmation mail. Do not continue until
they confirm the board opened. That mail is the only proof the relay from step 4 works, and
until the link is followed every page says `Pending Activation`.

Once they confirm, close the installer and prove it is closed:

```bash
cd ~/selfhost/fider
sed 's/^SIGNUP_DISABLED=false$/SIGNUP_DISABLED=true/' .env > .env.next && mv .env.next .env
chmod 600 .env
docker compose up -d --force-recreate fider
sleep 45
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8181/signup
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8181/
```

Assert: /signup prints `404` and the bare address prints `200`. Both must pass; the rewrite
avoids `sed -i`, spelled differently on macOS. Migrations are done by now, so 45 seconds is
enough for a recreate.

## 8. First backup and restore

Two artifacts: a dump with every post, vote, comment, account and image, and a config archive
with the two files that rebuild the service.

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

Assert: both exist and are non-empty. Print both sizes. Nothing stops: `pg_dump` snapshots a
running database.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a
synced folder or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is
written `/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are there, or
say plainly this install has no backup.

To restore, in this order. In ~/selfhost/fider, untar the config archive first, so compose.yml
and .env are back before any container starts: PostgreSQL reads `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 fider -d fider`, and
`docker compose up -d`. The archive matters as much as the dump: `JWT_SECRET` signs every
session.

## 9. Updating later

New versions are listed at https://github.com/getfider/fider/releases, the tags that exist as
images at https://hub.docker.com/r/getfider/fider/tags. Check the second: on 2026-08-07 the
newest release was v0.36.1 with no image under that name, which is why this pins v0.36.0. Do not
answer that with the floating `stable` tag. Take both backups first, then edit the image line in
compose.yml to the new tag and digest:

```bash
cd ~/selfhost/fider
docker compose pull
docker compose up -d
docker compose logs --tail 40 fider
```

Watch that log until it settles, then re-run step 7's `/_health` check.

## 10. What will probably go wrong

I rebooted this machine, opened the board I had made the day before, and got a connection error
that reads exactly like a lost database. It was not. Docker Desktop had not started with the
session, nothing was listening on 8181, and `restart: unless-stopped` acts only once the Docker
daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/fider && docker compose up -d` before concluding anything is broken.

## 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 change `BASE_URL` to this machine's LAN address and do not rebind 8181 to 0.0.0.0 so a
  colleague can vote. That puts a board that mails sign-in links on every network they join.
- Do not configure Google, Facebook or GitHub sign-in. Each is an app registered in somebody
  else's console; this install signs people in by email.
- Do not switch `BLOB_STORAGE` to `s3` or `fs`, and do not set `HOST_MODE` to multi-tenant.
````

## docker-compose.yml

```yaml
# Fider · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker hosting ... https://docs.fider.io/hosting-instance
#   configuration .... https://github.com/getfider/fider/blob/v0.36.0/app/pkg/env/env.go
#   health route ..... https://github.com/getfider/fider/blob/v0.36.0/app/cmd/routes.go
#   image entrypoint . https://github.com/getfider/fider/blob/v0.36.0/Dockerfile
#
# Two services: Fider and the PostgreSQL that holds every post, vote, comment,
# account and uploaded image, because BLOB_STORAGE defaults to sql. Upstream
# requires PostgreSQL 12 or newer; this pins 16. Upstream's guide runs
# getfider/fider:stable, a tag that moves under you, so this pins the newest
# version tag the registry carries, v0.36.0, by digest. LOG_SQL is off:
# upstream defaults it to true, inserting every log line into a logs table
# nothing reads or trims. `fider migrate` runs before the server listens, which
# the health check's start period allows for. Digests read on 2026-08-07; both
# images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: fider-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: fider
      POSTGRES_USER: fider
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/fider/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fider -d fider"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  fider:
    image: getfider/fider:v0.36.0@sha256:466669b3c932158d7fc082d4037ad881fce6c5cd49cf973e15d9bcaedc27889a
    container_name: fider
    restart: unless-stopped
    env_file: /srv/fider/.env
    environment:
      DATABASE_URL: postgres://fider:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable
      # Upstream's default is true, which writes every log line into a logs
      # table nothing reads. The console log is unaffected.
      LOG_SQL: "false"
    healthcheck:
      # The image ships `fider ping`, which asks its own /_health route.
      test: ["CMD", "./fider", "ping"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    ports:
      - "127.0.0.1:8181:3000"
    depends_on:
      postgres:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Fider · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker hosting ... https://docs.fider.io/hosting-instance
#   configuration .... https://github.com/getfider/fider/blob/v0.36.0/app/pkg/env/env.go
#
# Two services, paths relative to ~/selfhost/fider/ so one file works on
# macOS, Linux and Windows. The database is a named volume, not a bind mount:
# the PostgreSQL image chowns its data directory to a uid Docker Desktop
# cannot grant on a Windows home directory. Upstream wants PostgreSQL 12 or
# newer and runs the floating getfider/fider:stable; this pins 16, and v0.36.0
# by digest, the newest version tag the registry carries. BLOB_STORAGE
# defaults to sql, so images are rows too, and LOG_SQL is off because
# upstream's default writes every log line into a table nothing reads.
# Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: fider-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: fider
      POSTGRES_USER: fider
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - fider-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U fider -d fider"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  fider:
    image: getfider/fider:v0.36.0@sha256:466669b3c932158d7fc082d4037ad881fce6c5cd49cf973e15d9bcaedc27889a
    container_name: fider
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgres://fider:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable
      LOG_SQL: "false"
    healthcheck:
      # `fider ping` ships in the image and asks its own /_health route.
      test: ["CMD", "./fider", "ping"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    ports:
      - "127.0.0.1:8181:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  fider-pgdata:
```

## Caddyfile

```text
# Fider · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.fider.io/hosting-instance,
# https://docs.fider.io/how-to-enable-ssl and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, and Fider builds every sign-in link it mails out of
# BASE_URL, so the two have to agree exactly.

<DOMAIN> {
	# Fider already sets Content-Security-Policy, X-Content-Type-Options and
	# Referrer-Policy itself, so this adds only the two it does not: HSTS,
	# because the way in is a link that arrives by mail, and a framing rule,
	# because Fider ships no embeddable widget.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Frame-Options "SAMEORIGIN"
		-Server
	}

	encode zstd gzip

	# 8181 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8181
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Fider · 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=feedback.example.com \
#   EMAIL_NOREPLY=noreply@example.com \
#   SMTP_HOST=smtp.example.com SMTP_USERNAME=apikey SMTP_PASSWORD=... ./install.sh
#
# SMTP_PASSWORD is read from the environment and never written to your terminal.
# Start that command line with a space if your shell records history.
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.fider.io/hosting-instance
#   https://docs.fider.io/how-to-enable-ssl
#   https://github.com/getfider/fider/blob/v0.36.0/app/pkg/env/env.go
#   https://github.com/getfider/fider/blob/v0.36.0/app/cmd/routes.go
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the token-signing key. Both go into /srv/fider/.env with mode 600 and neither
# is ever printed.
#
# DOMAIN_HOST is also BASE_URL, the address inside every sign-in link this
# instance mails. Choose it once. Changing it later invalidates links people
# are already holding.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/fider}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
EMAIL_NOREPLY="${EMAIL_NOREPLY:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USERNAME="${SMTP_USERNAME:-}"
SMTP_PASSWORD="${SMTP_PASSWORD:-}"

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. feedback.example.com"
[ -n "$EMAIL_NOREPLY" ] || die "set EMAIL_NOREPLY; Fider requires a from-address and will not start without one"
[ -n "$SMTP_HOST" ] || die "set SMTP_HOST; sign-in links arrive by mail and there is no other way in"
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 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 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 ----------------------------------------------------

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 two secrets, on the server ------------------------------
#
# Hex for both. The database password rides inside a connection string, where
# hex needs no escaping, and 64 bytes of it clears the 512 bits upstream's own
# secret generator recommends for JWT_SECRET. Read them later with
#   sudo grep -E 'POSTGRES_PASSWORD|JWT_SECRET' /srv/fider/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		BASE_URL=https://${DOMAIN_HOST}
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		JWT_SECRET=$(openssl rand -hex 64)
		SIGNUP_DISABLED=false
		EMAIL_NOREPLY=${EMAIL_NOREPLY}
		EMAIL_SMTP_HOST=${SMTP_HOST}
		EMAIL_SMTP_PORT=${SMTP_PORT}
		EMAIL_SMTP_USERNAME=${SMTP_USERNAME}
		EMAIL_SMTP_PASSWORD=${SMTP_PASSWORD}
	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-fider"
	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 neither 8181 nor 5432 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8181 and 5432 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 image runs `fider migrate` before the server listens, so a first boot
# takes minutes rather than seconds.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/_health" | grep -q '"status":"Healthy"' \
	|| die "/_health answered 200 without status Healthy. Check: docker compose logs --tail 60 fider"

# With no board created yet, Fider redirects the bare hostname to its installer.
root_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$root_code" = "307" ] || die "https://${DOMAIN_HOST}/ returned ${root_code}, not the 307 an uninstalled Fider sends"

installer="$(curl -sS "https://${DOMAIN_HOST}/signup" | grep -c 'Sign up for Fider and let your customers share' || true)"
[ "$installer" = "1" ] || die "https://${DOMAIN_HOST}/signup did not render the expected first screen"

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

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

cat <<-DONE

	Fider is answering at https://${DOMAIN_HOST}/signup

	  1. Open that page, fill in your name, your email and the name of the
	     board, and submit. Fider mails you a confirmation link; that mail
	     arriving is the only proof your relay works. Until you follow the
	     link, every page reads "Pending Activation". If it never lands, read
	       docker compose logs --tail 60 fider
	     and check the from-address your relay will accept.
	  2. The installer is still open, which on a public hostname is a board
	     anyone who finds your address can claim. Once your board exists,
	     close it:
	       cd $APP_DIR
	       sudo sed -i 's/^SIGNUP_DISABLED=false\$/SIGNUP_DISABLED=true/' .env
	       docker compose up -d --force-recreate fider
	     Then confirm: curl -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/signup
	     must print 404, and https://${DOMAIN_HOST}/ must print 200.
	  3. Your two secrets are in $APP_DIR/.env, mode 600. Neither was printed
	     here. JWT_SECRET signs every session and sign-in link, so back up
	     .env with the database, not separately.
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE
```

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