Can I self-host Canny?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called Fider. It takes one prompt, a 1024 MB VPS, and about 210 minutes. That is $99 a month you stop paying Canny — $1,188 a year on the Pro plan, a metered rate, not a whole bill.
Why people pay for Canny
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Canny's product is the loop, not the board. Feedback arrives from a widget, an inbox, a sales call or an integration, gets matched against what people have already asked for, carries the name of the account that asked, and comes back out as a public roadmap and a changelog your users are notified about. The board is the visible part; the bill is attached to knowing which paying customer wants which thing, and being able to tell them when it shipped.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | 25 tracked users and 5 managers. Upstream defines a tracked user as anyone with a post, vote or comment attributed to them. |
| Prothe plan this page prices against | $99/mo metered | $99/mo month-to-month at the entry tier of 100 tracked users, or $79/mo billed yearly. The price steps up as tracked users grow, reaching about $1,349/mo month-to-month at 5,000. Includes 10 managers. |
| Business | quote only | Quote only, billed yearly. The pricing page says contact us, and this is the only tier that carries SSO and CRM integrations. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by Fider
One project, named before the prompt, so you know what you are about to install.
A feedback board on your own domain: customers post ideas, everyone votes, and nobody bills you per tracked user.
The only one of these that is a maintained, two-container install of the thing you actually open: a board where customers post ideas, everyone votes, and you set the status that drives the roadmap view. What you take on is mail. Fider has no passwords, so an SMTP relay is not an optional extra here, it is the front door, and the board cannot even be created until a confirmation email lands. Budget the weekend for the relay, not for the app.
The swap
You'd run
Fider
ONE WEEKEND · ~210 min to running · 1024 MB RAM
Canny Pro · a metered rate, not a whole bill · vendor list price · checked 2026-08-07 · source · confidence: medium
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~210 min3–24 hours, through the first backup
The prompt
Two paths to the same Fider: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
336 lines · 14,847 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
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.335 lines · 14,940 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout57 lines
# 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:agent-readable mirror: /self-host/canny.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned58 lines
# 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_healthyCaddyfilethe hostname and TLS29 lines
# 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.shthe same install, no agent172 lines
#!/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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Canny.
- You get the board, not the suite. Fider gives you posts, votes, statuses, comments, tags, a roadmap view built from those statuses, an admin area and a REST API. Canny's price covers that plus a changelog your users are notified about, deduplication of incoming requests, and integrations that attach a request to the account that asked for it and to the ticket that answers it. If what you open every week is the board, this is a fair trade; if it is the customer record next to each vote, it is not.
- Mail is the product, not a nicety. Fider has no passwords at all: signing in is a link mailed to you, creating the board in the first place means following a confirmation email, and the container refuses to start until it is told where to post mail. A relay that silently refuses your messages is a locked front door, so this install stops and asks for relay credentials before it starts anything.
- Nine years of releases, still on 0.x. Fider tagged v0.1.0 in March 2017 and v0.36.1 in July 2026, lately about one release every one to three months, which is slower and more conservative than most of this catalogue. The registry lags the tags as well: v0.36.1 shipped on GitHub with no image published under that name, so this install pins v0.36.0, the newest version tag Docker Hub actually carries, rather than the floating stable tag upstream's own guide uses.
- One PostgreSQL holds all of it. Posts, votes, comments, accounts and uploaded images are rows, because BLOB_STORAGE defaults to sql, so a single dump is the whole backup and there is no object store to secure. Upstream also defaults LOG_SQL to true, which writes every log line into a table nothing reads and no job trims; this install turns that off so the database stays the product rather than the logfile.
- No SSO, no CRM linkage, no revenue weighting. Those are what the paid tiers sell on top of a board, and the code here does not have them. Anyone who can receive email can make an account on your board unless you set it private in the admin area, which is a setting you choose rather than a default this install picks for you.
Where this came from
“You need a valid SMTP server to start the application, or you will see panic: could not find environment variable named 'EMAIL_SMTP_HOST' errors.”
- Upstream requires PostgreSQL 12 or newer and either an SMTP server or a Mailgun account, and its own compose example runs two services with the application on container port 3000. source
- JWT_SECRET, DATABASE_URL and EMAIL_NOREPLY are marked required, BASE_URL is mandatory in the default single-host mode, and with no Mailgun or SES credentials the mail type falls back to SMTP, which then forces EMAIL_SMTP_HOST and EMAIL_SMTP_PORT to be set. source
- The unauthenticated /_health route pings the database and returns a small JSON object reading status Healthy, which is also what the image's own health check asks for through fider ping. source
- Until a board exists, single-host mode redirects every tenant route to /signup, and setting SIGNUP_DISABLED makes that installer answer 404 instead. source
- LOG_SQL defaults to true, which inserts every log line at or above the configured level into a logs table in the same database, and no background job trims it. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Canny?
Not Canny itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Fider. A feedback board on your own domain: customers post ideas, everyone votes, and nobody bills you per tracked user. The install is one weekend: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 210 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Canny?
Fider. A feedback board on your own domain: customers post ideas, everyone votes, and nobody bills you per tracked user. The only one of these that is a maintained, two-container install of the thing you actually open: a board where customers post ideas, everyone votes, and you set the status that drives the roadmap view. What you take on is mail. Fider has no passwords, so an SMTP relay is not an optional extra here, it is the front door, and the board cannot even be created until a confirmation email lands. Budget the weekend for the relay, not for the app. Fider is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Canny?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Fider itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Canny Pro, $99/mo — $1,188 a year, a metered rate, not a whole bill.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: a database plus one outside integration. A database and an outside service fail in completely different ways, and you have to learn both failure modes before you trust the thing with real data. Budget the second day for whichever one surprises you. The tier is derived from seven countable facts about the Fider install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Fider on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Fider on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer the board answers only at http://localhost:8181, so the customers whose feedback you wanted cannot open it and what you have is a private list of your own ideas with a vote button. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.