Self-hosted spare Steam key / gift-link share for a small group of friends. Replaces the Humble Bundle spreadsheet.
Stack: Go (Fiber) + Postgres 16 + SvelteKit 2 (Svelte 5) + Tailwind 4.
cp .env.example .env
# set SUPERADMIN_EMAIL and AUTH_JWT_SECRET
docker compose -f docker-compose.dev.yml up --buildDev compose puts Traefik in front (same host / path split idea as production):
- App: http://humbleshare.localhost
- Games catalog: http://humbleshare.localhost/games
- Health: http://humbleshare.localhost/health
- Login: http://humbleshare.localhost/login
- Request access: http://humbleshare.localhost/request-access
- Traefik dashboard: http://localhost:8081
- Mailpit (caught email): http://localhost:8025
*.localhost resolves to 127.0.0.1 on current browsers / macOS. Traefik routes /api/v1, /health, and /webhooks to the Go API; everything else (including the SvelteKit BFF under /api/...) goes to the web container.
On first boot the API creates a superadmin for SUPERADMIN_EMAIL. If SUPERADMIN_PASSWORD is set (Infisical /api/auth key superadmin_password), the account is created immediately. Otherwise an invite is emailed (Mailpit in dev).
Or run pieces on the host (no Traefik; hit Vite/API ports directly):
docker compose -f docker-compose.dev.yml up -d postgres mailpit
cd api && cp ../.env.example .env
make dev
cd web && npm i && npm run devConfig precedence: env > CLI > optional TOML > Infisical > defaults.
When Infisical Machine Identity bootstrap env is set, secrets are loaded on startup
(API: /api recursively; web: /ui recursively). Failures abort startup. Process env
still overrides Infisical for operational tweaks.
| Path | Key | Env |
|---|---|---|
/api/db |
url |
DATABASE_URL |
/api/db |
ca_cert |
PEM; written to a temp file and PGSSLROOTCERT is set if unset |
/api/auth |
jwt_secret |
AUTH_JWT_SECRET |
/api/auth |
superadmin_email |
SUPERADMIN_EMAIL |
/api/auth |
superadmin_password |
SUPERADMIN_PASSWORD |
/api/auth/superadmin_password |
password |
SUPERADMIN_PASSWORD |
/api/auth |
cookie_secure |
COOKIE_SECURE |
/api/auth |
session_max_age_seconds |
SESSION_MAX_AGE_SECONDS |
/api/auth |
public_web_base_url |
PUBLIC_WEB_BASE_URL |
/api/mailgun |
api_key, domain, from, api_base |
MAILGUN_* |
/api/auth/google |
client_id, client_secret |
GOOGLE_OAUTH_* |
/api/auth/discord |
client_id, client_secret |
DISCORD_OAUTH_* |
/api/auth/steam |
api_key |
STEAM_API_KEY |
/api/auth/turnstile |
site_key, secret_key |
TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY |
/api/r2 |
account_id, access_key_id, secret_access_key, bucket, public_base_url, user_assets_bucket, user_assets_public_base_url |
R2_* (static + user-assets buckets) |
/api/steam |
claim_rate_limit_per_minute / metadata_cache_ttl_seconds / rate_per_second |
claim / Steam knobs |
/api/sentry |
dsn, environment, release, traces_sample_rate |
SENTRY_* (API) |
/ui/api |
internal_url |
API_INTERNAL_URL |
/ui/auth |
cookie_secure |
COOKIE_SECURE |
/ui/sentry |
dsn, server_dsn, environment, release, traces_sample_rate |
PUBLIC_SENTRY_DSN / SENTRY_* (web) |
Operational knobs such as HTTP_ADDR and CORS_ORIGINS stay on env/TOML.
| Variable | Service | Description |
|---|---|---|
DATABASE_URL |
API | Postgres connection string |
AUTH_JWT_SECRET |
API | Signs 2FA pending / invite approve / OAuth state JWTs |
SUPERADMIN_EMAIL |
API | First superadmin email |
SUPERADMIN_PASSWORD |
API | Optional; creates that account on boot (skips invite) |
MAILGUN_API_KEY / MAILGUN_DOMAIN |
API | Production email (ignored when SMTP_HOST is set) |
SMTP_HOST / SMTP_PORT / SMTP_FROM |
API | Local SMTP (Mailpit in docker-compose.dev.yml) |
PUBLIC_WEB_BASE_URL |
API | Browser origin for invite/OAuth links |
API_INTERNAL_URL |
Web | Go API base URL for the BFF |
SENTRY_DSN |
API (+ optional web server) | Sentry DSN; empty disables the SDK |
PUBLIC_SENTRY_DSN |
Web | Browser Sentry DSN (same project DSN is fine) |
SENTRY_ENVIRONMENT / SENTRY_RELEASE |
API + Web | Environment and release tags |
COOKIE_SECURE |
API + Web | true in production HTTPS |
SESSION_MAX_AGE_SECONDS |
API | Session cookie max-age (default 30d) |
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY |
API | Shared R2 credentials |
R2_BUCKET / R2_PUBLIC_BASE_URL |
API | Static assets bucket + public CDN (https://assets.humbleshare.com) |
R2_USER_ASSETS_BUCKET / R2_USER_ASSETS_PUBLIC_BASE_URL |
API | User uploads (avatars, banners) + public CDN (https://user-assets.humbleshare.com) |
- Visitors submit Request access with an email.
- Admins (and
SUPERADMIN_EMAIL) get a Mailgun notification; approve in Admin or via the email link. - The requester gets an invite email, sets a password, and signs in.
- Optional: enable TOTP 2FA and link Google / Discord / Steam from Settings.
- Superadmin can promote other users to
adminin the portal.
Sessions use an opaque humbleshare_session cookie (hashed at rest). The SvelteKit BFF proxies /api/* so the browser never talks to Go with secrets in client JS.
For Admin → Import:
- Log into humblebundle.com in a browser.
- DevTools → Application/Storage → Cookies → copy
_simpleauth_sess. - Paste into the import form: Humble gift links (
gift?key=…) and/or order gamekeys (downloads?key=…). Steam CD keys go in Bulk paste. - The cookie is sent only for that request and is never written to the database or logs.
Root docker-compose.yml is the Traefik/Infisical production stack (GHCR images on an external shared-network). Build and push humbleshare-api / humbleshare-web via CI; prefer Infisical for secrets (process env remains a valid override).
Dev Traefik mirrors production router names (humbleshare-api / humbleshare-web) and priorities. Local uses plain HTTP on :80 and PathPrefix(/api/v1) so OAuth callbacks hit Go while the BFF keeps /api/*. Production labels still use the broader PathPrefix(/api) (TLS + Let's Encrypt on the external Traefik).
- Claiming is a single conditional
UPDATE … WHERE status = 'available'(seeClaimOldestAvailable). Concurrent losers get 409. - Public game list never includes
code_value. The key appears only after a successful claim (and on admin inventory). - Game identity comes from Humble titles or manual entry; Steam metadata is resolved from the title, never from the opaque key.
- Claims store
claimed_by_user_idplus a denormalized display name.