Foedus

Self-hosted wedding landing site with guest management and RSVP collection

About

Fœdus is a self-hosted web app for wedding logistics: a landing site for the couple, dedicated invitations for each guest (or guest group), RSVP collection, a collaborative Spotify playlist, a money-transfer gift registry and a small AI chatbot to answer guests' questions.

It's designed to be deployed once per wedding: spin it up, configure it via the admin dashboard, share the public URL and per-guest invitation links, and let it collect everything you'd otherwise gather via WhatsApp threads and spreadsheets.

Usage

Public site

The homepage (/) shows the couple's landing page: ceremony and reception venues, an optional collaborative Spotify soundtrack, places that mattered to the couple, honeymoon stops on a map, and a gift registry with per-item progress.

Each guest gets a dedicated invitation URL of the form https://your-domain/<code>, where <code> is a short opaque identifier generated for each invitation from the dashboard. The invitation page renders the personalized invite, lets the guest RSVP, and — when visited the first time — flips a "viewed" flag in the database so the couple can see who has actually opened theirs.

Loading the homepage with ?invite=<code> unlocks two extra affordances: a floating button to update the RSVP, and the input box on the soundtrack section that lets the guest add tracks to the Spotify playlist.

Admin dashboard

The admin lives at /dashboard, behind HTTP basic auth (credentials set via ADMIN_USER and ADMIN_PASSWORD). From there, the couple can:

Docker

The simplest way to run it is via the bundled compose file:

ADMIN_USER=admin ADMIN_PASSWORD=secret docker compose up -d

This starts Fœdus on port 3000, with the SQLite database persisted in a named Docker volume.

Reverse proxy

Fœdus expects to sit behind a reverse proxy in production (nginx, caddy, etc.) — the proxy terminates TLS and forwards to Fœdus on its plain HTTP port. Make sure to forward the X-Forwarded-For header so per-IP rate limiters bucket correctly: set TRUSTED_PROXIES to the comma-separated list of upstream IPs in addition to loopback.

Chatbot

If OPENROUTER_API_KEY is set, a small chat bubble appears on the homepage. It uses OpenRouter as the LLM gateway and defaults to meta-llama/llama-3.3-70b-instruct:free; override via OPENROUTER_MODEL.

The bot is fed the wedding settings as context, so it can answer common guest questions ("what time does the ceremony start?", "is there parking?") without the couple having to repeat themselves.

Collaborative soundtrack

If SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET and SPOTIFY_REFRESH_TOKEN are set, and a playlist URL is configured in the dashboard, invited guests can search Spotify and queue tracks to the wedding playlist directly from the homepage. The search box is gated on the invite query param — visitors arriving without an invite see a disabled hint instead.

Installation

Docker

The recommended way to run Fœdus is via Docker, pulling the prebuilt image from GHCR:

docker pull ghcr.io/streambinder/foedus:latest

A docker-compose.yml is shipped at the repository root, ready to be tweaked:

ADMIN_USER=admin ADMIN_PASSWORD=secret docker compose up -d

The compose file persists the SQLite database in a named volume (foedus_data) mounted at /data inside the container.

Custom build

Fœdus is plain Go, so the usual toolchain works. templ is needed once to generate the view code:

git clone https://github.com/streambinder/foedus.git
cd foedus
go install github.com/a-h/templ/cmd/templ@latest
templ generate
go build
./foedus

To stamp the binary with a cache-busting asset version (used to invalidate /static/* and /media/* on the client), pass an ASSET_VERSION value through -ldflags — this is what the Dockerfile does on every build:

ASSET_VERSION="$(date -u +%Y%m%d%H%M%S)" \
go build -ldflags "-X github.com/streambinder/foedus/internal/buildinfo.AssetVersion=${ASSET_VERSION}"

Configuration

Fœdus reads its configuration from environment variables:

VariableRequiredDefaultPurpose
ADMIN_USERyes*HTTP basic auth username for /dashboard
ADMIN_PASSWORDyes*HTTP basic auth password for /dashboard
ADMIN_USER1..9noAdditional admin usernames (numbered suffix); paired with the same-numbered ADMIN_PASSWORD1..9. At least one pair is required overall, else the app refuses to boot.
ADMIN_PASSWORD1..9noSee above
DATABASE_URLnofoedus.dbPath to the SQLite file
PORTno3000TCP port to bind
TRUSTED_PROXIESno127.0.0.1,::1Extra reverse-proxy IPs to trust for X-Forwarded-For (loopback is always trusted regardless)
LOG_LEVELnoinfoslog level: debug, info, warn, error
LOG_FORMATnotexttext (default) or json for structured logging
OPENROUTER_API_KEYnoEnables the homepage chatbot via OpenRouter
OPENROUTER_MODELnometa-llama/llama-3.3-70b-instruct:freeOpenRouter model name
SPOTIFY_CLIENT_IDnoEnables the collaborative soundtrack
SPOTIFY_CLIENT_SECRETnoSame as above
SPOTIFY_REFRESH_TOKENnoSame as above

* The app accepts either the unsuffixed ADMIN_USER / ADMIN_PASSWORD pair or any number of suffixed ADMIN_USER1..9 / ADMIN_PASSWORD1..9 pairs — at least one valid pair must be set, otherwise the process panics at startup rather than expose the dashboard with default credentials.

Everything else (couple names, venues, photos, gift list, accommodations, guest list, invitations, polls) lives in the SQLite database and is configured from the admin dashboard at /dashboard.

Design

Fœdus is a single Go binary serving a Fiber-based HTTP app, with views rendered server-side via templ and state persisted in a single SQLite file.

There is no separate frontend build pipeline: the only first-party JavaScript shipped is a handful of vanilla .js files under static/, served gzipped and cached aggressively. No SPA, no bundler, no node toolchain at runtime. static/places.js is the one exception that loads a third-party library (Leaflet) at runtime from unpkg.com rather than bundling it.

Routes

The router is split into three logical groups, registered in this order:

  1. Public unauthenticated routes (/, /media/*, /og-image, /gift/claim, /chat, /soundtrack/*). Every state-changing POST goes through a 256KB body cap and a CSRF token issued via a cookie on every public GET. Per-IP rate limiting (10 req/min) is applied additionally to /chat and /soundtrack/add only, since those endpoints fan out to an LLM provider and to write-heavy SQLite paths respectively. RSVP, gift-claim and invitation-viewed POSTs rely on the CSRF + body-cap pair without an explicit rate limiter.
  2. Admin routes mounted under /dashboard, gated by HTTP basic auth and a stricter same-site CSRF policy. The dashboard accepts up to 32MB per request because the settings form ships base64-encoded photos inline.
  3. Invitation catch-all routes (/:code, /:code/viewed, /:code/rsvp), registered last so they don't shadow any other path.

Storage

A single SQLite file holds everything: settings, guests, invitations, RSVPs, gifts, soundtrack events, polls. Schema is created in code via idempotent CREATE TABLE blocks; manual ALTER TABLE statements are applied out-of-band when columns need to change, then mirrored into the CREATE block for fresh installs.

Photos and other binary assets are stored as blobs in the same database, served via /media/:id. This keeps the deployment a single mountable volume — no separate object storage to back up.

Templating

All HTML is generated by templ: .templ files compile to plain Go that streams strings into the response. Helper functions live alongside templates in the templates/ package, so any logic that's awkward to express in templ syntax (URL shaping, conditional section ordering, JSON payload assembly) ends up as a regular Go function.

Observability

Structured logs go to stdout via log/slog. An access-log middleware annotates every request with method, path, status, latency and the resolved client IP (post-X-Forwarded-For). A /healthz endpoint returns 200 unconditionally and is wired into the Docker HEALTHCHECK.

Internationalization

Translations live in a single internal/i18n/i18n.go map keyed by language code. The currently shipped languages are English and Italian. The dashboard exposes a subset of keys (HomepageKeys) that the couple can override per-language without redeploying, so wording on the public site can be tweaked without code changes.

Why so few moving parts

The deployment target is "a small VPS owned by the couple, running for ~6 months, then shut down". The whole stack picks the smallest viable option at every layer: SQLite over Postgres, server-rendered HTML over SPA, vanilla JS over framework, single binary over microservices. The cost of operating it should be a couple of euros a month and zero ongoing maintenance.