- Go 80.4%
- HTML 7.7%
- PLpgSQL 4.1%
- JavaScript 3%
- CSS 2.1%
- Other 2.7%
Replaces the "Send message" link with "This is your profile." when the viewer and the profile subject are the same person, in both English and Polish locales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| cmd/forum | ||
| deploy | ||
| internal | ||
| scripts | ||
| .gitignore | ||
| Containerfile | ||
| forum.example.toml | ||
| go.mod | ||
| go.sum | ||
| INSTALL.md | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
| sqlc.yaml | ||
forum
Self-hostable discussion board for neighborhood communities — a replacement for Facebook groups. Server-rendered Go application with PostgreSQL as the only infrastructure dependency.
Design goals
- One static binary + PostgreSQL (with the pgmq extension). Nothing else.
- Works without JavaScript; JS only progressively enhances. Installable as a PWA.
- Notifications first: web push and email digests are the core feature — members don't scroll the site all day, the site comes to them.
- Private, invite-only communities with an invite-token economy (admins grant tokens; 1 invite = 1 token; revoked/expired invites refund).
- Multi-tenant: one deployment hosts several communities, resolved by URL
path (
/osiedle/...) or by host. Accounts are global, profiles per community. - EU/GDPR by design: data export, account erasure, EXIF stripping, no tracking, one-click unsubscribe, Polish-first UI (pl/en).
Quick start
Requirements: Go 1.26+, PostgreSQL 18, and SMTP credentials (any EU
transactional provider; Mailpit in
development). Postgres needs two one-time additions — the pgmq
extension and a Polish full-text search configuration. On a server,
Bare-metal PostgreSQL (Debian) walks
through both in five short steps; for development, the
deploy/db/Containerfile image comes with both baked in.
Fast path — interactive wizard (recommended):
make build
./forum setup # prompts for DB, community, admin invite in one go
./forum serve # HTTP server
./forum worker # notification worker (separate process)
forum setup walks through database configuration, applies migrations,
generates VAPID keys, bootstraps the first community and category, and
prints an admin invitation link. Start the two processes and you're done.
Manual path:
cp forum.example.toml forum.toml # set database.url, smtp, etc.
make build
./forum migrate # apply schema migrations (run on every deploy)
./forum admin gen-vapid # once; put the keys in forum.toml's [vapid] table
./forum serve # HTTP server
./forum worker # notification worker (separate process)
Bootstrap the first community and its admin:
./forum admin create-community -slug osiedle -name "Nasze Osiedle"
./forum admin create-category -community osiedle -slug ogolne -name "Ogólne"
./forum admin invite -community osiedle -email you@example.eu -role admin
# open the printed accept link, pick a display name — you're in
From there everything happens in the browser: admins grant invite tokens on the Members page, members invite neighbors, moderators handle the report queue.
Architecture
cmd/forum— one binary, four modes:serve(HTTP),worker(notifications, digests, maintenance),migrate,admin(operator CLI).internal/web— HTTP controllers, embedded templates and static files. Every mutation is a plain form POST with CSRF; JS (embedded, no build step) only enhances. Strict same-origin CSP.internal/db— the single data-access layer: sqlc-generated queries over pgx, tern migrations (embedded), and hand-written pgmq helpers. Every app table lives in its ownforumPostgres schema (notpublic), created and put on the database's defaultsearch_pathbydb.Migrate'sensureSchemabefore any migration runs, so migration files and queries never need to schema-qualify a table name.forum resetdrops and recreates only that schema; the Polish full-text search baseline (tsearchschema, see Search) and thepgmqextension's own schema are untouched by either.- Use-case packages (
accounts,invites,content,mod,gdpr,media,notify) coordinate DAL calls; operations run in one transaction per request. - Transactional outbox: creating a thread/post enqueues an event onto pgmq in the same transaction. The worker fans events out into per-recipient web-push jobs, delivers them (pruning dead subscriptions), and builds daily/weekly email digests directly from content, with per-profile hour/timezone and exactly-once claims. RFC 8058 one-click unsubscribe on every digest.
- Invite-token economy correctness: conditional decrement (balance never negative), guarded status transitions (no double accept/revoke), and a partial unique index making refunds exactly-once — verified by concurrency tests.
- Attachments: images are re-encoded (EXIF/GPS stripped), downscaled,
thumbnailed, stored per community behind a
BlobStoreinterface — local filesystem (FSStore) by default, or S3-compatible (S3Store) for multi-replica deployments.
Search
Threads and posts are searchable (GET /search), ranked by relevance, using
native Postgres full-text search — no separate search service. Matching is
community-scoped and stemmed for Polish, so an inflected query like
samochody also finds posts containing samochód.
Stock PostgreSQL (through 18) ships no Polish text-search configuration.
PostgreSQL 19 includes one built in (config name
polish) — but this repo currently targets PostgreSQL 18, so the
equivalent is built from the Debian hunspell dictionary. Three pieces,
wherever the database runs:
- Dictionary files in the server's
tsearch_datadirectory: the Debianhunspell-plfiles transcoded from ISO-8859-2 to UTF-8 (Postgres's ispell reader does not transcode on its own — the classic gotcha, also called out in the article above), plus a small stopword list (deploy/db/tsearch_data/polish.stop). deploy/db/init-polish-fts.sql— registers those files as thepolishconfiguration, in a dedicatedtsearchschema that survivesforum reset(the script's header comments explain the details). Run by hand in production (Bare-metal PostgreSQL step 4); the dev image runs it automatically at first boot.- The migration (
internal/db/migrations/001_init.sql, search section) then builds GIN indexes against that configuration — which is why the configuration must exist before the firstforum migrate.
For development, build the image before starting a fresh dev database,
then migrate as usual:
podman build -t localhost/forum-db:latest -f deploy/db/Containerfile deploy/db
Useful checks when working on search (from the article above):
\dF polish -- confirm the configuration exists
select * from ts_debug('polish', 'Sprzedam stary samochód.'); -- inspect tokenization
select to_tsvector('polish', 'Sprzedam stary samochód.'); -- quick stemming smoke test
The hand-built configuration is deliberately named polish so
PostgreSQL 19 makes all of this disappear: skip the dictionary files
and the init script and its built-in configuration satisfies the same
indexes and queries, which reference the configuration only by name.
Development
make test # unit tests
TEST_DATABASE_URL=postgres://forum:forum@localhost:5432/forum_test make test
# + integration tests (needs pgmq-enabled Postgres)
make check # vet + tests + govulncheck; run before pushing
make vulncheck # just the vulnerability scan
make sqlc # regenerate the DAL after editing queries/migrations
go run ./scripts/genicons # regenerate PWA icons
There is no CI, so make check is the pre-push gate — export
TEST_DATABASE_URL first or the integration tests skip themselves and it
tells you far less than it appears to (it warns when they will).
Set DEV=1 for pretty console logs. Without SMTP configured, emails are
logged instead of sent — magic-link URLs appear in the log.
Deployment
A production deployment is the static forum binary (under systemd or
in a container) behind a TLS-terminating reverse proxy, talking to
PostgreSQL — typically distro-packaged, prepared once as described in
Bare-metal PostgreSQL (Debian) below.
The repo also ships container tooling:
Containerfile— static binary in a distroless image.deploy/db/Containerfile— Postgres with pgmq and Polish full-text search (see Search) for development and container-only setups. In production, admins typically run distro-packaged PostgreSQL instead — see Bare-metal PostgreSQL (Debian).deploy/podman-pod.yaml— single-host Podman pod: the database image above + web + worker (podman kube play).deploy/k8s.yaml— Kubernetes: web/worker Deployments, migrate Job, attachments PVC.
Run migrate on every deploy before starting serve/worker. Back up
Postgres (pg_dump/PITR) and the attachments directory — recipes
in Backup and restore.
Bare-metal PostgreSQL (Debian)
The expected production setup: PostgreSQL installed from distribution
packages, managed like any other system service. The app needs exactly
two things stock Postgres doesn't ship — the pgmq extension and a
Polish text search configuration; everything else is plain
Postgres. (The deploy/db container image exists to automate these
same two steps for development, not as a deployment artifact.)
1. PostgreSQL 18. Debian 13 packages PostgreSQL 17; this project targets 18, so add the PGDG repository:
sudo apt install postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install postgresql-18
2. pgmq. Since 1.x pgmq is a pure-SQL extension — nothing to
compile, make install just copies the control and SQL files into the
server's share directory:
sudo apt install git make postgresql-server-dev-18 # pg_config + PGXS
git clone --depth 1 --branch v1.12.0 https://github.com/pgmq/pgmq.git
sudo make -C pgmq/pgmq-extension install PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config
(Pick the latest release tag; pgmq supports Postgres 14–18.)
3. Polish dictionary files (the procedure from depesz's
article; see Search for why the app needs
them). Debian packages the hunspell dictionary, just in the wrong
encoding for Postgres — so convert to UTF-8 on the way in and rewrite
the affix file's SET declaration to match, and the head -1 check
at the end confirms it worked:
sudo apt install hunspell-pl
SHAREDIR=$(/usr/lib/postgresql/18/bin/pg_config --sharedir)
sudo sh -c "iconv -f ISO8859-2 -t UTF-8 /usr/share/hunspell/pl_PL.dic \
> $SHAREDIR/tsearch_data/polish.dict"
sudo sh -c "iconv -f ISO8859-2 -t UTF-8 /usr/share/hunspell/pl_PL.aff \
| sed 's/^SET ISO8859-2/SET UTF-8/' > $SHAREDIR/tsearch_data/polish.affix"
sudo install -m 644 deploy/db/tsearch_data/polish.stop "$SHAREDIR/tsearch_data/"
head -1 "$SHAREDIR/tsearch_data/polish.affix" # must print: SET UTF-8
4. Database, role, extension, FTS configuration. The SQL the
container image runs at first boot (deploy/db/init-polish-fts.sql)
is the same script to run by hand here. create extension is
superuser-only, so do it now — migration 001's
create extension if not exists pgmq then no-ops for the app user:
sudo -u postgres createuser forum -P
sudo -u postgres createdb -O forum forum
sudo -u postgres psql -d forum -c 'create extension pgmq'
sudo -u postgres psql -d forum -f deploy/db/init-polish-fts.sql
5. Verify, then hand over to the app — set database.url in
forum.toml and run forum migrate as usual:
sudo -u postgres psql -d forum \
-c "select to_tsvector('polish', 'Nowy samochód i stare samochody')"
# expect both inflected forms stemmed to one shared 'samochód' lexeme
That's the whole special part — done once, before the first forum migrate (migration 010 builds indexes against the polish
configuration), and from here on it's ordinary Postgres
administration. Minor apt upgrades are safe: dpkg leaves the
hand-placed tsearch_data/polish.* and pgmq extension files alone. On
a major version upgrade (18 → 19), redo step 2 against the new
version's pg_config — and drop step 3 and the init script entirely,
because PostgreSQL 19 ships its own polish configuration (see
Search). One caveat: managed databases (RDS and friends)
don't let you place files in tsearch_data, so they need PostgreSQL
19+.
Reverse proxy. The server speaks plain HTTP and expects TLS to be
terminated by a fronting proxy (keep HTTPS=1 so cookies are Secure
and HSTS is sent). Enable response compression at the proxy — the app
deliberately does not gzip in-process: encode zstd gzip (Caddy) or
gzip on; gzip_types text/html text/css application/javascript image/svg+xml; (nginx). Set PROXY_HEADER (e.g. X-Forwarded-For)
only when the proxy always overwrites it.
Database pool. DB_MAX_CONNS (default 16) and DB_MIN_CONNS
(default 2) size each process's pool — serve and worker each hold
their own, so budget Postgres max_connections accordingly.
DB_STATEMENT_TIMEOUT (default 30s, 0 disables) caps any single
query server-side; migrate/reset ignore it so long backfills can
run.
Custom snippets. Drop Markdown files into custom.d/ (next to the
binary) to customize the site without touching code. Recognized files:
header.md— injected into the site header on every page (after the nav). Empty by default.footer.md— injected into the site footer on every page (after the legal links). Empty by default.privacy.md— replaces the built-in/privacyplaceholder.terms.md— replaces the built-in/termsplaceholder.flyer-intro.md— replaces the built-in flyer introduction forforum admin gen-flyers.
Files are rendered as Markdown (sanitized) once at startup; restart
serve after editing. Missing files fall back to built-in defaults.
Unknown files are ignored. The directory is gitignored.
Operational notes
- iOS web push requires the PWA to be installed to the home screen — the email digest is the universal fallback channel, which is why it is first-class.
- Multiple
workerprocesses are safe (pgmq visibility timeouts, guarded digest claims); multipleserveprocesses are safe behind a load balancer (sessions live in Postgres). Note that rate limits are per-process, so N replicas multiply every abuse limit by N. - Memory. A
serveprocess idles around 20-30 MiB. Upload decoding is the main pressure —MaxDecodePixels(24 MP) bounds the decompression buffer to ~72 MiB. Budget 128-256 Mi perservereplica to cover concurrent uploads. The worker is lighter and sits comfortably at 32-64 Mi. /healthz(liveness) and/readyz(DB reachability) are wired for probes.- Every response carries an
X-Request-Idheader, and every request log line (including panic reports) carries the samerequest_idfield — when a user reports an error page, ask for the ID and grep the logs.
Metrics
Set METRICS_TOKEN and forum serve exposes GET /metrics in
Prometheus text format; without the token the endpoint does not exist
(scrape output reveals activity levels of a private community, so it is
never open). Scrapes authenticate with the token as a bearer credential:
scrape_configs:
- job_name: forum
scrape_interval: 30s
authorization:
credentials: <METRICS_TOKEN>
static_configs:
- targets: ["forum.example:8080"]
Exported:
forum_http_requests_total{class="2xx".."5xx"},forum_http_request_duration_seconds(histogram) — this process's request traffic.forum_queue_depth{queue=...},forum_queue_oldest_message_age_seconds{queue=...}— pgmq health. A growing oldest-age means the worker is down or stuck; this is the first thing to alert on (e.g.> 600forauth_email).forum_queue_archived_messages{queue=...}— poison messages parked in the pgmq archive awaiting inspection. Alert on> 0.forum_bus_live_peers— other serve/worker instances that answered a real bus ping (the same fan-out asforum admin ping). With N processes deployed, alert when it stays below N−1. The ping waits 1s, so budget scrape timeouts for it.forum_metrics_db_up— 0 when the scrape could not collect the queue gauges from Postgres.
Queue gauges are read from the shared database, so scraping one serve
replica covers them; HTTP metrics are per-process, so scrape every
replica.
Backup and restore
Postgres holds everything — content, sessions, invites, and the pgmq mail queues — so the database backup is the whole disaster story; the blob store (attachments) is the only other stateful piece.
# Logical dump (-Fc enables pg_restore -j and selective restore)
sudo -u postgres pg_dump -Fc forum > forum-$(date +%F).dump
# (container dev DB: podman exec pgmq-postgres pg_dump -U postgres -Fc forum)
# Attachments (fs mode; S3 mode: use the provider's bucket replication)
tar -C data -czf attachments-$(date +%F).tar.gz attachments
Verify once that the dump actually contains the queue tables
(pg_restore -l forum-*.dump | grep q_auth_email): pgmq creates them
via pgmq.create(), and on old pgmq versions they were members of the
extension, which pg_dump silently skips.
Restore into an empty database on a server with the pgmq extension
files and Polish dictionaries installed (steps 2–3 of
Bare-metal PostgreSQL (Debian), or
the deploy/db image), then reconcile:
createdb forum && pg_restore --no-owner -d forum forum-<date>.dump
forum migrate # idempotent; fills any gap between dump and binary
Order matters for consistency: dump the database before snapshotting
blobs. A blob store newer than the database leaves orphan files
(harmless); the reverse leaves attachment rows whose downloads 404.
Take backups from a cron job, keep at least one copy off the host, and
restore into a scratch database periodically — a backup that has never
been restored is a hope, not a backup. For point-in-time recovery put
WAL archiving (e.g. wal-g, pgBackRest) in front of the same
database; nothing in the app needs special handling for it.
Multi-host deployments
Attachments are served through the app process — never via presigned
URLs or a public bucket — to keep private-community content
membership-gated. When running more than one serve replica, every
replica must see the same blob store.
Option 1 — Shared filesystem (simplest, no code change). Mount a
single directory at ATTACHMENTS_DIR on every replica. Keep
ATTACHMENT_STORE=fs (the default). Common approaches:
- Kubernetes: provision a
ReadWriteManyPersistentVolume. This requires a StorageClass whose CSI driver supports multi-node read-write, e.g. the NFS subdir provisioner (nfs-subdir-external- provisioner), AWS EFS CSI driver, Azure Files, or CephFS. The built-in k8s manifest (deploy/k8s.yaml) already declaresaccessModes: [ReadWriteMany]— only the StorageClass needs to match. - Bare metal / podman: point
ATTACHMENTS_DIRat an NFS mount (or any network filesystem) shared between hosts. Podman kube play volumes also support NFS volumes directly. - S3-as-filesystem: tools like s3fs-fuse or rclone mount can expose an S3 bucket as a local directory, combining the cheap durability of S3 with simple POSIX access — but test atomic-rename behavior carefully; not all FUSE drivers support it.
The atomic-rename guarantee in FSStore.Save holds only within one
real filesystem; NFSv4 satisfies this, most FUSE drivers do not. All
replicas read and write the same directory; failure mode is shared-disk,
so choose a reliable backing store.
Option 2 — S3-compatible backend (true horizontal scale). Set
ATTACHMENT_STORE=s3 and the S3_* environment variables. Works with
any S3-compatible service: AWS S3, Cloudflare R2, OVH Object Storage,
RustFS, MinIO, etc. Buckets stay completely private — the server holds
the only access credentials. Atomicity comes from single-PUT semantics;
reads stream directly from the object store. No filesystem to share or
mount; each replica is independently stateless in terms of file
I/O. There is no /readyz blob ping, so a bucket outage only 404s
specific downloads without taking a replica out of the LB rotation.
Privacy & access. In both modes the handler enforces community
membership before opening any blob (internal/web/handlers_media.go:97).
The app stays in the data path — bytes flow from the store through the
server to the client. Cache-Control headers are private so proxies
never share across users. Membership revocation takes effect immediately
because there is no durable URL the user could hold independently.
Migrating between FS and S3
forum admin copy-blobs -from <fs|s3> -to <fs|s3> copies every
attachment and avatar between backends using the configured
ATTACHMENTS_DIR and S3_* values. Steps for an fs → s3 migration:
- Set
S3_*in the environment alongside the existingATTACHMENTS_DIR. - Run
forum admin copy-blobs -from fs -to s3 -confirm. - Change
ATTACHMENT_STORE=s3, restartserve. - Verify uploads and downloads work before removing the old filesystem.
The reverse direction (s3 → fs) works the same way. The command
copies every attachment row (full + thumbnail) and every profile
avatar; it is idempotent — retrying after a partial failure skips
objects already present in the destination.