Skip to content

Repository files navigation

OpenTaps

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.

Repository layout

This monorepo contains two independently deployable applications:

The backend is authoritative — the frontend is a pure consumer of its REST API.

What it tracks

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.

Authentication & authorization

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.
  • RolesAdmin (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 sessions table and delivered to the browser as an HttpOnly, SameSite=Lax cookie. Sessions last 30 days by default.
  • Public routesGET /api/public/menu and GET /api/calendar/feed.ics stay 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.

Quick start

Backend

cd opentaps-backend
cp .env.example .env
docker compose up --build
docker compose exec api alembic upgrade head

Then browse:

Frontend

cd opentaps-frontend
npm install
cp .env.example .env   # PUBLIC_API_URL=/api works out of the box in dev
npm run dev

Default 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.

Production deployment

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.

1. Prerequisites

  • Docker Engine 24+ and the docker compose v2 plugin.
  • A DNS record (or /etc/hosts entry) 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.

2. Configure

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_PASSWORD on a fresh deployment only — Postgres bakes it into the initialized data directory on first run. Changing it later requires a matching ALTER USER inside the DB.

3. Pull & run

docker compose pull
docker compose up -d

That's it. Three things happen automatically:

  1. Postgres initializes on the opentaps_pgdata volume.
  2. The backend runs alembic upgrade head before starting Uvicorn.
  3. 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.

4. Upgrade

docker compose pull        # Grabs the new :latest tag
docker compose up -d       # Re-creates the changed containers

Migrations 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.

5. Rollback

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 -d

6. HTTPS / public hosting

The 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.

6.1 Split public/admin hostnames (recommended for public menus)

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.

7. Backups

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).dump

Restore with pg_restore -d "$POSTGRES_DB" backups/<file>.dump against a fresh, empty database (drop and recreate opentaps first if needed).

8. CI/CD in one glance

.github/workflows/docker-image.yml runs on every push to main:

  1. Matrix job builds opentaps-backend and opentaps-frontend in parallel.
  2. QEMU + Buildx produce linux/amd64 and linux/arm64 layers.
  3. GitHub Actions cache (type=gha, scoped per image) speeds up subsequent runs.
  4. docker/login-action authenticates with DOCKERHUB_USERNAME + DOCKERHUB_TOKEN (repository secrets).
  5. Images are tagged :latest and :<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.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages