Revel is an open-source event management, ticketing and membership platform for communities, clubs, independent venues and independent artists.
This repository is the Docker Compose deployment for Revel: the stack, three Caddyfile variants, the setup.sh wizard for self-hosters and the scripts we use to run letsrevel.io. For what Revel does and who it is for, read the main README. The full self-hosting guide is at docs.letsrevel.io/self-hosting.
You need:
- A Linux x86-64 server (the images are built for
linux/amd64only). - A root shell, or a user in the
dockergroup. git,opensslandcurl.- DNS A records for two hostnames: one for the web app and one for the API, which defaults to
api.<your domain>. Add a third for Grafana if you enable observability. - Ports 80 and 443 open.
Then:
git clone https://github.com/letsrevel/infra && cd infra && ./setup.shThe wizard is interactive and has no unattended mode. It:
- Offers to install Docker (via
get.docker.com) if it is missing, and checks that ports 80 and 443 are free. - Suggests a tier from the detected CPU and RAM.
- Asks for the frontend and API domains, SMTP (or dry-run email; with real SMTP it also offers an optional dedicated sending domain for organization mail and an optional bounce-webhook secret, both left out of
.envwhen blank and kept on a re-run) and which optional services to enable: observability (plus its Grafana domain), ClamAV, the Telegram bot, LLM questionnaire evaluation, Stripe, Google login, the login canary and the OAuth / OpenID Connect provider. It also asks whether you are behind Cloudflare. - Asks whether this is a single-organization instance (the default), which turns off public organization creation.
- Backs up any existing
.env, writes a new one with generated secrets (plus, if the OAuth provider is on, a signing key incerts/oidc.pemthat is created once and never replaced) and picks the matching Caddyfile. - Downloads the city list (and, optionally, the 182 MB IP2Location LITE database), hands
media/,geo-data/andsentinel/to the containers' user (see Data directory ownership), pulls the images fromghcr.io/letsreveland runsdocker compose up -d. - Once the API is healthy, registers Stripe webhooks (if Stripe is enabled and
jqis installed) and creates the admin user and first organization.
Caddy obtains Let's Encrypt certificates on first start. Behind Cloudflare, keep the DNS records DNS-only (gray cloud) until the certificates are issued; the wizard pauses for this.
A tier is a preset of defaults for the questions above and for resource limits. Every optional service can still be switched on or off on its own.
| Tier | Hardware | Runs |
|---|---|---|
| Slim | 2 vCPU, 4 GB RAM | Web app, API, Celery worker and beat, PostgreSQL/PostGIS, PgBouncer, Redis, Caddy |
| Full | 8 vCPU, 32 GB RAM | Slim plus any of: Grafana/Loki/Tempo/Prometheus observability, ClamAV, the Telegram bot, a login canary. The wizard defaults observability and ClamAV to on; Telegram and the canary stay opt-in |
The slim tier costs about €20/month (Hetzner CPX22, September 2026). letsrevel.io runs the full tier on a Hetzner CCX33 (8 dedicated vCPU, 32 GB RAM, 240 GB disk). The wizard suggests full only on hosts with at least 8 vCPU and 24 GB RAM, and warns below 2 vCPU and 3.5 GB.
| Slim | Full | |
|---|---|---|
| Gunicorn workers x threads | 2 x 2 | 6 x 4 |
| Celery concurrency | 2 | 4 |
Postgres shared_buffers / max_connections |
256MB / 50 | 4GB / 100 |
| Web memory limit | 1500m | 12g |
Always running: caddy, web (Gunicorn, gthread workers), celery_default, beat, frontend, revel_postgres (PostGIS 17), pgbouncer and redis.
Optional services are Compose profiles, listed in COMPOSE_PROFILES in .env. Most profiles have a matching feature flag that the backend reads, and the wizard sets both together. GET /api/version reports some of the flags (organization creation, Telegram, LLM evaluation) so the web app can hide features that are off.
| Profile | Services | Flag |
|---|---|---|
observability |
grafana, prometheus, alertmanager, loki, tempo, pyroscope, alloy, postgres-exporter, redis-exporter, node_exporter, blackbox-exporter | FEATURE_OBSERVABILITY |
antivirus |
clamav | FEATURE_MALWARE_SCAN |
telegram |
telegram | FEATURE_TELEGRAM |
canary |
canary (synthetic login check) | none |
Flags with no service behind them: FEATURE_LLM_EVALUATION (with LLM_*), FEATURE_ORGANIZATION_CREATION (off for single-organization instances), Stripe keys, OIDC_PROVIDERS for user login through Google or any OpenID Connect provider, GOOGLE_SSO_* for the Django admin login, APPLE_WALLET_*, GOOGLE_WALLET_*, INTEGRATIONS_EVENTBRITE_* and OIDC_SIGNING_KEY_PATH with OAUTH_ISSUER for the OAuth provider. All are optional. SMTP is optional too, but without it nobody receives verification or ticket emails. See .env.example for every variable; its values document our own production deployment.
The wizard asks for one Google OAuth client and uses it for two separate toggles. User-facing login writes OIDC_PROVIDERS=google with OIDC_GOOGLE_ISSUER, OIDC_GOOGLE_CLIENT_ID and OIDC_GOOGLE_CLIENT_SECRET; add https://<API_DOMAIN>/api/auth/oidc/google/callback as a redirect URI on that client. Admin login writes GOOGLE_SSO_*. To add another OpenID Connect provider, see tiers and configuration.
Any SMTP provider works; letsrevel.io uses the Brevo SMTP relay (smtp-relay.brevo.com, port 587, TLS). A few optional variables sit next to the SMTP settings:
ORG_EMAIL_DOMAIN: a dedicated sending domain (for examplemail.<your domain>) for mail that organizations send to people: announcements, invitations, event updates and reminders. This keeps its reputation apart from account mail such as verification, password reset and tickets. Unset, that mail uses the domain ofDEFAULT_FROM_EMAIL. Authenticate the domain (DKIM and DMARC) at your SMTP provider before you set it. It needs no MX record, but do not publish a null MX: RFC 7505 says a null-MX domain should not be used as a From domain.EMAIL_WEBHOOK_SECRET: turns on the bounce and complaint webhook atPOST https://<API_DOMAIN>/api/email-events/brevo. The endpoint acceptsAuthorization: Bearer <secret>or basic auth with the secret as password, so in Brevo's webhook settings you can usehttps://revel:<secret>@<API_DOMAIN>/api/email-events/brevo. Unset, the endpoint returns 404.ORG_NUDGE_REPLY_TOandORG_NUDGE_SIGNATURE: Reply-To and check-in signature for the org setup nudges, which are short reminders to owners of stalled organizations (private profile, forgotten draft, no events, gone quiet). Without a Reply-To the emails still go out, but the personal "what's in the way?" check-in never does. The daily task ships disabled: preview withdocker compose exec web python manage.py org_nudges, then enable "Send org setup nudges" under Periodic tasks in the Django admin.
Domains come from .env (FRONTEND_DOMAIN, API_DOMAIN and GRAFANA_DOMAIN, plus DOCS_DOMAIN in the production Caddyfile only). Pick a variant with CADDYFILE_PATH; the wizard sets it.
Caddyfile: Cloudflare-aware plus legacy redirects. The production default; its fallbacks resolve to the letsrevel.io domains.Caddyfile.cloudflare: self-hosting behind Cloudflare's proxy.Caddyfile.generic: self-hosting with Caddy as the edge.
Never buffer frontend responses. The frontend uses SvelteKit streaming SSR. A flush_interval -1 on the frontend reverse_proxy makes Caddy hold the whole response, and pages hang on a loading state until SSR finishes. None of the shipped Caddyfiles set it. To check a deployment, compare time to first byte with total time; they should differ:
curl -w "\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" -o /dev/null "https://<your-frontend-domain>/events"All three Caddyfiles can serve organizations on their own hostnames (#67; the backend side is the white-label operator guide). The pieces:
CUSTOM_DOMAIN_CNAME_TARGET(for examplecustomers.letsrevel.io) is the name organizers CNAME to. Caddy serves it (a redirect to the frontend) so it has its own certificate; behind Cloudflare it is also the Cloudflare for SaaS fallback origin, set up grey first, orange once Caddy has its certificate. Unset, the block resolves to an inert.localhostname.- An
https://catch-all at the end of each file serves the frontend for any other hostname, with on-demand TLS: Caddy gets a Let's Encrypt certificate for a customer hostname only after the backend'saskendpoint (GET /api/tls/ask?domain=<host>, called straight over the docker network, unthrottled) answers 200 (an ACTIVE domain of an organization withcustom_domain_enabled). The backend must be deployed with that endpoint before Caddy is reloaded, or every ask is refused. Behind Cloudflare this is what satisfies Full (strict): Cloudflare connects with the customer hostname as SNI and Host. Only HTTP-01 is used there, since TLS-ALPN cannot cross Cloudflare's proxy. - Every frontend proxy overwrites
X-Forwarded-HostandX-Forwarded-Proto, and the frontend runs withHOST_HEADER/PROTOCOL_HEADERandPLATFORM_HOSTSinstead ofORIGIN. KeepORIGINout of.env. - The catch-all sends HSTS without
includeSubDomainsorpreload: those domains are not ours. - Protected media echoes
Access-Control-Allow-Originfrom Django's CORS decision, so it works on the platform and on active custom domains alike.CORS_ALLOWED_ORIGINSmust list the frontend origin (the wizard sets it). - Changing
CUSTOM_DOMAIN_CNAME_TARGETneedsdocker compose up -d caddy(a recreate): Caddy reads.envat container start, not oncaddy reload.
The Cloudflare token and zone id, the URL template and the Turnstile hostname list are described in .env.example.
The backend containers (web, celery_default, beat, telegram) run as a non-root user, appuser (uid/gid 997). They write to bind-mounted directories from the checkout: media/ (uploads, logos, generated PDFs and wallet passes; all four), plus geo-data/ (the periodic IP2Location refresh) and sentinel/ (the LLM sentinel model) for web and celery_default only. A fresh clone belongs to whoever cloned it, so without a chown those writes fail with PermissionError: [Errno 13]. setup.sh reads the uid/gid from the image and runs the chown for you (with sudo when not root). On an install that did not go through the wizard:
docker compose run --rm --no-deps --entrypoint id web # expect uid=997(appuser) gid=997(appuser)
sudo chown -R 997:997 media geo-data sentinelApple Wallet passes need a Pass Type ID certificate, its private key and Apple's WWDR certificate. Put them in certs/, which is mounted into the containers at /app/certs, and point APPLE_WALLET_CERT_PATH, APPLE_WALLET_KEY_PATH and APPLE_WALLET_WWDR_CERT_PATH at them (for example /app/certs/pass-certificate.pem). Also set APPLE_WALLET_PASS_TYPE_ID, APPLE_WALLET_TEAM_ID and, if the key has one, APPLE_WALLET_KEY_PASSWORD.
The containers run as a non-root user whose UID differs from the host's, so every file, including the key, must be mode 644. With 600, pass generation fails with PermissionError: [Errno 13] Permission denied: '/app/certs/pass-certificate.pem'.
chmod 644 certs/*.pemGoogle Wallet setup is in the self-hosting docs.
Revel can act as an OAuth 2.1 / OpenID Connect provider, so third-party apps and MCP hosts can act on a user's behalf and other sites can offer "Sign in with" your instance. It is off by default and turns on only when both OIDC_SIGNING_KEY_PATH points at a readable RSA private key and OAUTH_ISSUER is set; otherwise every /o/* and discovery route returns 404. The wizard asks about it (default: no) and, if you say yes, generates the key, sets both variables and applies the permissions. By hand:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out certs/oidc.pem
chmod 644 certs/oidc.pemOIDC_SIGNING_KEY_PATH=/app/certs/oidc.pem
OAUTH_ISSUER=https://<API_DOMAIN>
OAUTH_ISSUER is exactly the public API origin, with no trailing slash. The key needs mode 644 for the same reason as the wallet certificates. If the containers cannot read it, the provider stays off while the rest of the API keeps serving, and every management command warns oauth.W002 (visible in docker compose logs web). Set OAUTH_ISSUER together with the key: a readable key without an issuer fails the backend's oauth.E001 check, and web (which migrates on start) does not start. No Caddy changes are needed: the API site block already proxies /o/* and /.well-known/* to Django. To check it works, https://<API_DOMAIN>/api/version should report features.oauth_provider: true, and the issuer in https://<API_DOMAIN>/.well-known/openid-configuration should equal OAUTH_ISSUER.
Never regenerate the key in place. A new key invalidates every ID token already issued; access and refresh tokens live in the database and are not affected. The wizard reuses an existing certs/oidc.pem on every re-run. To rotate, generate a new key, point OIDC_SIGNING_KEY_PATH at it and list the old path in OIDC_SIGNING_KEYS_INACTIVE_PATHS (comma-separated). Tuning variables are in .env.example.
./deploy.sh update # pull the latest images and redeploy
./deploy.sh backup # pg_dump to backup_<timestamp>.sql
./deploy.sh logs web # also: up, down, restart, ps, pull
docker compose up -d --scale celery_default=4deploy-rollout.sh replaces containers with docker-rollout instead of stopping them first, safe-reboot.sh drains Celery tasks before rebooting the host and ALERTING_SETUP.md covers Pushover alerts and Grafana dashboards. Persistent data lives in named volumes (revel_postgres_data, redis_data, caddy_data, caddy_config and clamav_data, plus one per observability service). docker compose down -v deletes all of it.
./deploy.sh backup covers the database only. Back up .env and certs/ (wallet certificates and the OAuth signing key certs/oidc.pem) separately. Both are gitignored, so they are not in your clone's history either.
Before exposing an instance: change every default password in .env, keep .env out of version control and keep the images updated.
MIT. See LICENSE.
- revel-backend: the Django API and the main project page
- revel-frontend: the SvelteKit web app
- .github: the organization profile