A free & open source, self-hosted platform for tracking what's on tap.
OpenTaps is a homelab-friendly beverage and homebrew tracking system. It manages the full lifecycle of recipes, batches, physical kegs, kegerators/taps, and ingredient inventory, exposes IoT telemetry ingestion for sensors like tilt hydrometers, and drives a public "what's on tap" digital menu for guests.
This monorepo contains two independently deployable applications:
- opentaps-backend/ — FastAPI + PostgreSQL service that
owns all business rules and data. Ships with Alembic migrations, an OpenAPI
spec at
/docs, an unauthenticated public menu endpoint, and an iCal feed for brew scheduling. See opentaps-backend/README.md for the full API surface and setup. - opentaps-frontend/ — SvelteKit + TypeScript + Tailwind
Progressive Web App. Serves both the operator console (recipes, batches,
kegs, kegerators/taps, calendar) and the public "on tap" digital bar menu.
Packaged as a PWA via
@vite-pwa/sveltekitand deployed with the Node adapter behind a multi-stage Docker image. See opentaps-frontend/README.md for details, and the design/architecture notes in .trae/documents/opentaps-frontend-prd.md and .trae/documents/opentaps-frontend-tech-architecture.md.
The backend is authoritative — the frontend is a pure consumer of its REST API.
| Domain | Purpose |
|---|---|
| Recipes | Formulas for Beer / Mead / Cider / Wine / Kombucha / Soda. Homebrew or Commercial. Portable JSON export/import for sharing between instances. |
| Batches | Concrete instances of a recipe moving through a brew-day state machine. |
| Kegs | Physical vessels with UUID IDs (QR-friendly), capacity, connector, and lifecycle. |
| Kegerators | Cooling units with a bounded number of taps. |
| Taps | Ordered positions on a kegerator; a keg can only be on one tap at a time. |
| Inventory | Ingredients, hardware, cleaning supplies, packaging — with reorder thresholds. |
| Telemetry | Sensor readings (temperature, gravity, battery) optionally linked to a batch. |
| Public menu | Unauthenticated view of active taps — the guest-facing "digital bar menu". |
| Users | Session-cookie authentication with Admin / Editor / Viewer roles. First-run bootstrap wizard creates the initial admin. |
OpenTaps ships with a session-cookie auth layer:
- First run — the frontend detects that no admin exists and drops you into a bootstrap wizard to create the initial admin. Subsequent users can only be added by an admin from the Users page.
- Roles —
Admin(full access, manages users),Editor(read + write on all resources),Viewer(read-only). Every mutating endpoint (POST/PATCH/DELETE) requires the writer role; the users router requires admin. - Sessions — opaque tokens stored server-side in a
sessionstable and delivered to the browser as anHttpOnly,SameSite=Laxcookie. Sessions last 30 days by default. - Public routes —
GET /api/public/menuandGET /api/calendar/feed.icsstay unauthenticated so guests and calendar apps can consume them without credentials.
Because the session cookie uses SameSite=Lax, the frontend and backend must share an origin in the browser. In dev, that's handled by the Vite proxy that forwards /api/* from the SvelteKit dev server to FastAPI. In production, put both behind the same reverse proxy (Caddy / Nginx / Traefik) and route /api/* to the API service.
cd opentaps-backend
cp .env.example .env
docker compose up --build
docker compose exec api alembic upgrade headThen browse:
- Swagger UI: http://localhost:8000/docs
- Public menu API: http://localhost:8000/api/public/menu
- Calendar feed: http://localhost:8000/api/calendar/feed.ics
cd opentaps-frontend
npm install
cp .env.example .env # PUBLIC_API_URL=/api works out of the box in dev
npm run devDefault dev server: http://localhost:5173. The Vite dev proxy forwards
/api/* to the FastAPI backend, which is why PUBLIC_API_URL=/api (a
same-origin relative path) is the recommended default — it keeps the session
cookie same-origin so login persists. Node ≥ 20 is required for the PWA build
(workbox-build).
On first boot the frontend will land you on the bootstrap wizard to
create the first admin user. After that, everything else is gated behind the
login screen; admins can invite Editor / Viewer users from /users.
The repository root ships with a production docker-compose.yml that
pulls prebuilt multi-arch images from DockerHub and puts them behind a
built-in Caddy reverse proxy so the frontend and API share a single
browser-visible origin (a hard requirement for the session cookie — see
opentaps-frontend/SKILLS.md §1.5).
The images are published automatically by
.github/workflows/docker-image.yml
on every push to main:
jivandabeast/opentaps-backend:latest(and:<git-sha>)jivandabeast/opentaps-frontend:latest(and:<git-sha>)
Both are built for linux/amd64 and linux/arm64, so the same compose
file runs unchanged on an x86 homelab or a Raspberry Pi.
- Docker Engine 24+ and the
docker composev2 plugin. - A DNS record (or
/etc/hostsentry) pointing at the host — the app is fine on plain HTTP behind another edge proxy, but see the TLS note below if you need public HTTPS.
Create a .env next to docker-compose.yml. At minimum you need:
# REQUIRED — compose refuses to start without this.
# Any password works; the backend URL-encodes it internally when it
# builds the SQLAlchemy DSN, so @, :, /, #, %, +, spaces, etc. are all
# safe. Just paste the raw value here.
POSTGRES_PASSWORD=change-me-to-something-long-and-random
# Optional overrides (defaults shown)
POSTGRES_USER=opentaps
POSTGRES_DB=opentaps
OPENTAPS_HTTP_PORT=8080 # Host port the Caddy proxy binds to
OPENTAPS_IMAGE_TAG=latest # Or a specific commit SHA for pinning
APP_ENV=production
# CORS_ORIGINS defaults to ["http://localhost:8080"]. Override if you're
# hosting under a different scheme/host — must be a JSON array.
# CORS_ORIGINS=["https://opentaps.example.com"]Rotate
POSTGRES_PASSWORDon a fresh deployment only — Postgres bakes it into the initialized data directory on first run. Changing it later requires a matchingALTER USERinside the DB.
docker compose pull
docker compose up -dThat's it. Three things happen automatically:
- Postgres initializes on the
opentaps_pgdatavolume. - The backend runs
alembic upgrade headbefore starting Uvicorn. - Caddy binds to
${OPENTAPS_HTTP_PORT:-8080}and routes:/api/*,/docs,/openapi.json,/redoc→ backend- Everything else → frontend
Visit http://<host>:8080/ and you'll land on the bootstrap wizard to
create the first admin. On subsequent visits you'll get the login screen.
docker compose pull # Grabs the new :latest tag
docker compose up -d # Re-creates the changed containersMigrations run on every backend start, so upgrades are one-shot. If you
want to pin a specific commit for reproducibility, set
OPENTAPS_IMAGE_TAG=<git-sha> in .env and up -d again.
Because every CI build pushes both :latest and :<git-sha>, rolling back
is a matter of pinning the tag:
OPENTAPS_IMAGE_TAG=<previous-good-sha> docker compose up -dThe bundled Caddy proxy speaks plain HTTP on the exposed port — plenty for
a homelab reachable over a VPN or Tailscale. For public HTTPS, front the
stack with an edge reverse proxy (another Caddy, Nginx, Traefik, or a
cloud load balancer) that terminates TLS and forwards to
http://<host>:${OPENTAPS_HTTP_PORT}. Same-origin is preserved as long as
the edge proxy exposes the API and UI on the same hostname.
If you'd rather have Caddy itself terminate TLS, swap :80 for your
domain in the embedded Caddyfile inside docker-compose.yml (the
configs.caddyfile.content block), map port 443:443, and Caddy's
automatic Let's Encrypt provisioning takes over.
If you want to expose only the read-only On Tap menu (/menu) to the
public internet while keeping the admin app on a private hostname —
useful for bars, taprooms, or anyone linking to a QR code — deploy with
two hostnames and lock the public one down with an allow-list. The
frontend is fully same-origin either way, so no CORS changes are needed
in .env; the isolation is enforced at the edge.
Nginx Proxy Manager — public host, "Advanced" tab
Create a proxy host for the public domain (e.g. menu.example.com)
pointing at the Caddy container's HTTP port. On the Advanced tab of
that host, paste an allow-list that rejects everything except the menu
route and its assets:
# Bare root → menu.
location = / {
return 302 /menu;
}
# Public SvelteKit page(s). ^~ blocks any regex location from stealing this.
location ^~ /menu {
proxy_pass http://<caddy-host>:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Single JSON endpoint the page fetches. Exact-match — no other /api slips through.
location = /api/public/menu {
proxy_pass http://<caddy-host>:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SvelteKit hashed assets + favicon.
location ^~ /_app/ {
proxy_pass http://<caddy-host>:8080;
proxy_set_header Host $host;
}
location = /favicon.ico { proxy_pass http://<caddy-host>:8080; access_log off; log_not_found off; }
location = /robots.txt {
add_header Content-Type text/plain;
return 200 "User-agent: *\nDisallow: /\n";
}
# Explicitly kill the admin API prefix.
location ^~ /api/ {
return 404;
}
# Catch-all: anything not listed above never reaches the app.
location / {
return 404;
}The admin host (e.g. internal.example.com) is a separate NPM proxy
host with default settings — no advanced config, no allow-list — since
it should serve the full app.
Updated Caddyfile — second gate behind NPM
Split the embedded Caddyfile in
docker-compose.yml (the
configs.caddyfile.content block) into two host blocks so Caddy
enforces the same invariant even if the NPM config is later loosened.
Use the http:// scheme prefix so Caddy does not try to acquire its
own TLS certs (NPM is already terminating TLS at the edge):
# Admin surface — only reachable via the internal hostname.
http://internal.example.com {
encode zstd gzip
@api path /api/* /api /docs /openapi.json /redoc
handle @api { reverse_proxy backend:8000 }
handle { reverse_proxy frontend:3000 }
}
# Public menu — reachable via the external hostname.
http://menu.example.com {
encode zstd gzip
# Exactly the JSON endpoint the menu needs. Nothing else under /api.
@publicApi path /api/public/menu
handle @publicApi { reverse_proxy backend:8000 }
# The menu route + SvelteKit's compiled assets.
@publicApp path /menu /menu/* /_app/* /favicon.ico /robots.txt
handle @publicApp { reverse_proxy frontend:3000 }
# Redirect bare root to the menu.
@root path /
redir @root /menu 302
# Everything else (any other /api/*, all admin routes, /docs,
# /openapi.json, …) returns 404 at the edge.
handle {
respond "Not Found" 404
}
}Substitute your real hostnames for internal.example.com and
menu.example.com. Reload the proxy container after editing:
docker compose up -d --force-recreate proxy(A plain docker compose restart proxy will not work — inline
configs: are rendered at container create-time, not at start-time.)
Verify the allow-list
curl -i https://menu.example.com/ # 302 → /menu
curl -i https://menu.example.com/menu # 200
curl -i https://menu.example.com/api/public/menu # 200 JSON
curl -i https://menu.example.com/api/kegs # 404
curl -i https://menu.example.com/kegs # 404
curl -i https://menu.example.com/docs # 404
curl -X POST -i https://menu.example.com/api/auth/login # 404
curl -i https://internal.example.com/kegs # 200 (or redirect to /login)Any expected 404 coming back as 200/401 means the allow-list has a
hole — check the NPM Advanced tab first, then the Caddyfile blocks.
Why layer both? Two independent allow-lists (NPM + Caddy) each guarding the same rule means a misconfiguration in one layer doesn't silently expose admin surface. The cost is a dozen extra lines of config; the upside is that swapping out either proxy later can't regress the security posture.
The stateful data lives in three named volumes:
opentaps_pgdata— the Postgres data directory (recipes, batches, users, sessions, telemetry).opentaps_caddy_data,opentaps_caddy_config— issued TLS certs and Caddy state (only matters if you enable HTTPS).
A minimal backup routine:
docker compose exec -T db \
pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" \
> backups/opentaps-$(date +%F).dumpRestore with pg_restore -d "$POSTGRES_DB" backups/<file>.dump against a
fresh, empty database (drop and recreate opentaps first if needed).
.github/workflows/docker-image.yml runs on every push to main:
- Matrix job builds
opentaps-backendandopentaps-frontendin parallel. - QEMU + Buildx produce
linux/amd64andlinux/arm64layers. - GitHub Actions cache (
type=gha, scoped per image) speeds up subsequent runs. docker/login-actionauthenticates withDOCKERHUB_USERNAME+DOCKERHUB_TOKEN(repository secrets).- Images are tagged
:latestand:<commit-sha>and pushed to DockerHub.
Trigger a rebuild manually from the Actions tab (workflow_dispatch)
if you need to re-publish without a commit.
See LICENSE.