Self-Hosting
Self-host Alphorn with Docker Compose, Podman, or from source - PostgreSQL 18 setup, all environment variables, web/worker modes, reverse proxy and HTTPS, backups, and updating.
Alphorn is fully open source (AGPL-3.0-or-later) and designed to be self-hosted. You need a PostgreSQL 18 database - that's the only external dependency.
Docker Compose (recommended)
The fastest way to get Alphorn running is the prebuilt image from the GitHub Container Registry:
services:
alphorn:
image: ghcr.io/alphorn-dev/alphorn:latest
ports:
- "3000:3000"
environment:
# PostgreSQL connection string
DATABASE_URL: postgresql://alphorn:secret@db:5432/alphorn
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET: your-secret-key
# Public URL where Alphorn will be accessible
BETTER_AUTH_URL: http://localhost:3000
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s # migrations run before the server starts
restart: unless-stopped
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: alphorn
POSTGRES_PASSWORD: secret
POSTGRES_DB: alphorn
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U alphorn"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:docker compose up -dAlphorn is available at http://localhost:3000. Create your first account there.
PostgreSQL 18 images store data under /var/lib/postgresql. Mount the volume there, not at /var/lib/postgresql/data like older versions.
The repository's own docker-compose.yml is the same setup, but builds the image from source (build: .) and reads settings from a .env file. It sets DATABASE_URL itself (overriding the one in .env) and takes the database password from POSTGRES_PASSWORD (default alphorn - change it). To use it:
git clone https://github.com/alphorn-dev/alphorn.git
cd alphorn
cp .env.example .env # set at least BETTER_AUTH_SECRET
docker compose up -dPodman
Podman is a drop-in replacement for Docker that runs rootless by default. The same compose file works with podman-compose or Podman's built-in podman compose:
podman compose up -dOr run the containers individually:
# Create a pod so containers can communicate
podman pod create --name alphorn -p 3000:3000
# Start PostgreSQL
podman run -d --pod alphorn --name alphorn-db --restart unless-stopped \
-e POSTGRES_USER=alphorn \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=alphorn \
-v pgdata:/var/lib/postgresql \
postgres:18-alpine
# Wait until PostgreSQL accepts connections - Alphorn exits if it can't migrate
until podman exec alphorn-db pg_isready -U alphorn; do sleep 1; done
# Start Alphorn
podman run -d --pod alphorn --name alphorn-app --restart unless-stopped \
-e DATABASE_URL=postgresql://alphorn:secret@localhost:5432/alphorn \
-e BETTER_AUTH_SECRET=your-secret-key \
-e BETTER_AUTH_URL=http://localhost:3000 \
ghcr.io/alphorn-dev/alphorn:latestAlphorn is available at http://localhost:3000.
Running from Source
Requirements: Node.js 22.12 or newer (the Docker image uses Node 24), pnpm, and a PostgreSQL 18 database.
git clone https://github.com/alphorn-dev/alphorn.git
cd alphorn
pnpm install
cp .env.example .env # set DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL
pnpm db:deploy # apply database migrations
pnpm build
pnpm startpnpm start runs the web app with the delivery worker embedded (MODE=all). Next.js prints a warning that next start doesn't fit output: standalone - it still works.
When running from source, migrations are not applied automatically. Run pnpm db:deploy after every update.
To run separate worker processes from source, see Architecture & Scaling.
Environment Variables
The .env.example in the repository lists every variable with comments.
Required
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string |
BETTER_AUTH_SECRET | Secret for sessions, encrypted 2FA secrets and backup codes, and the webhook loop-detection headers. Generate with openssl rand -base64 32. Don't change it later - all users are signed out and every user's 2FA stops working |
BETTER_AUTH_URL | Public URL of your instance, including https:// and without a trailing slash. Used for auth callbacks, email links, and the webhook URLs shown in the dashboard |
Sign-up
| Variable | Default | Description |
|---|---|---|
DISABLE_SIGNUP | false | Set to true for invite-only mode. The first account on an empty instance can still sign up; after that, new accounts need a pending email invitation. See Authentication → Invite-only mode |
Email (SMTP)
Alphorn sends system emails (verification, password reset, email change, invitations) over SMTP. All five variables must be set, otherwise email is switched off.
| Variable | Example | Description |
|---|---|---|
SMTP_HOST | smtp.example.com | SMTP server |
SMTP_PORT | 587 | 465 uses implicit TLS, 587 requires STARTTLS (sending fails if the server doesn't offer it). Other ports use STARTTLS only if the server offers it |
SMTP_USER | Username | |
SMTP_PASS | Password | |
SMTP_FROM | Alphorn <noreply@example.com> | Sender address |
With SMTP configured, new accounts must verify their email address. Without SMTP:
- Accounts are usable right after sign-up, without verification
- Password reset and email change are not available
- Members can only be invited with shareable links
- SSO logins can't be linked to existing email/password accounts, because those email addresses are never verified (see Authentication → SSO)
SMTP connections time out after 15 seconds. Failed emails are logged.
Runtime & Scaling
| Variable | Default | Description |
|---|---|---|
MODE | all | all (web + worker), web, or worker. Any other value stops the container with an error. See Architecture & Scaling |
WORKER_CONCURRENCY | 20 | Deliveries a worker process sends in parallel |
DATABASE_POOL_SIZE | 10 | Connection pool size of the app. The queue uses its own pool (up to 10 more connections) plus 2 listener connections, so plan for about 22 connections per process |
PORT | 3000 | HTTP port of the web server |
Limits
| Variable | Default | Description |
|---|---|---|
WEBHOOK_MAX_BODY_BYTES | 1048576 (1 MiB) | Larger webhook requests are rejected with 413 |
WEBHOOK_MAX_HOPS | 3 | How often a notification may be forwarded between Alphorn webhooks before it is rejected with 508 |
SSE_MAX_CONNECTIONS_PER_CHANNEL | 20 | Open stream connections per Live Stream channel |
SSE_MAX_CONNECTIONS_PER_IP | 20 | Open stream connections per client IP |
Live Stream (SSE)
| Variable | Default | Description |
|---|---|---|
SSE_MODE | - | Set to standalone to use the separate SSE server |
SSE_SERVER_URL | - | Internal URL of the SSE server, e.g. http://sse:4000 |
SSE_INTERNAL_SECRET | - | Shared secret between Alphorn and the SSE server |
SSE_PORT | 4000 | Port of the SSE server |
See Streaming → Self-Hosting for when you need this.
Logging & Error Tracking
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL | info | trace, debug, info, warn, error, or fatal. Logs are JSON in production |
BETTER_AUTH_LOG_LEVEL | warn | Log level of the auth library |
SENTRY_DSN | - | Sentry or GlitchTip DSN. Warnings and errors from the logs are sent there too |
SENTRY_ENVIRONMENT | production | Environment name in Sentry |
SENTRY_TRACES_SAMPLE_RATE | 0.1 | Share of requests traced |
Server-side error tracking works with the prebuilt image. Browser error tracking needs SENTRY_DSN at build time, so it only works when you build the image or app yourself.
Outbound Traffic
| Variable | Default | Description |
|---|---|---|
EGRESS_PROXY_URL | - | Send all outgoing fetch requests (all HTTP-based channel deliveries) through an HTTP proxy, e.g. an SSRF-filtering proxy like smokescreen. SMTP connections and error reports to Sentry don't go through it. With the standalone SSE server, the internal publish requests to SSE_SERVER_URL go through it too - allow that host in the proxy |
Alphorn does not block channel URLs that point into your private network (except URLs pointing at the Alphorn instance itself). If untrusted users can create channels on your instance, run the outgoing traffic through an SSRF-filtering proxy with EGRESS_PROXY_URL, and restrict outgoing SMTP traffic (port 25/465/587) at the network level - the Email (SMTP) channel connects directly.
Build-time
| Variable | Description |
|---|---|
APP_VERSION | Version shown in the dashboard and /api/health. Only read at build time. Docker builds use the APP_VERSION build argument (default dev); builds from source fall back to the git commit, then dev |
SSO / OAuth
Alphorn supports social login and enterprise SSO. Set the relevant environment variables to enable a provider - if the variables are absent, the provider is simply not shown on the login page.
GitHub
| Variable | Description |
|---|---|
GITHUB_CLIENT_ID | OAuth App Client ID |
GITHUB_CLIENT_SECRET | OAuth App Client Secret |
Create an OAuth App at GitHub → Settings → Developer settings → OAuth Apps. Set the callback URL to {BETTER_AUTH_URL}/api/auth/callback/github.
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID | OAuth Client ID |
GOOGLE_CLIENT_SECRET | OAuth Client Secret |
Create credentials at Google Cloud Console → APIs & Services → Credentials → OAuth client ID (Web application). Add {BETTER_AUTH_URL}/api/auth/callback/google as an authorized redirect URI.
Microsoft
| Variable | Description |
|---|---|
MICROSOFT_CLIENT_ID | Application (client) ID |
MICROSOFT_CLIENT_SECRET | Client secret value |
MICROSOFT_TENANT_ID | Tenant ID - use common for any Microsoft account, organizations for work/school only, consumers for personal only, or a specific tenant GUID (default: common) |
Register an app in Azure Portal → App registrations. Under Redirect URIs, add a Web URI: {BETTER_AUTH_URL}/api/auth/callback/microsoft.
Upgrading from an older version
Older Alphorn versions used the callback {BETTER_AUTH_URL}/api/auth/oauth2/callback/microsoft-entra-id. Add the new redirect URI above to your Azure app registration before upgrading - otherwise Microsoft sign-in fails with a redirect URI mismatch. You can remove the old URI afterwards.
Generic OIDC (Keycloak, Okta, Azure AD, etc.)
Connect any OIDC-compliant identity provider.
| Variable | Required | Description |
|---|---|---|
OIDC_CLIENT_ID | Yes | OIDC Client ID (enables the provider) |
OIDC_CLIENT_SECRET | Yes | OIDC Client Secret |
OIDC_DISCOVERY_URL | Recommended | OIDC discovery endpoint (auto-configures URLs below) |
OIDC_AUTHORIZATION_URL | No | Manual authorization endpoint (if not using discovery) |
OIDC_TOKEN_URL | No | Manual token endpoint (if not using discovery) |
OIDC_USERINFO_URL | No | Manual userinfo endpoint (if not using discovery) |
OIDC_PROVIDER_ID | No | Custom provider identifier (default: custom-oidc) |
OIDC_PROVIDER_NAME | No | Display name on the login page (default: SSO) |
OIDC_SCOPES | No | Space-separated scopes (default: openid profile email) |
OIDC_PKCE | No | Enable PKCE (default: false) |
Register {BETTER_AUTH_URL}/api/auth/callback/{OIDC_PROVIDER_ID} as the redirect URI in your identity provider (with the default ID: /api/auth/callback/custom-oidc).
The discovery document is fetched once at startup. If that fails, the SSO button is still shown but signing in with it fails, and only an error is logged - check the logs and restart once the provider is reachable.
Architecture & Scaling
Alphorn has three parts:
- Web - the dashboard, the webhook receiver (
/n/...), and Live Stream connections - Worker - sends deliveries from the queue, retries them, and runs the background sweep
- Queue - lives in PostgreSQL (pg-boss), so there is no Redis or other broker
With the default MODE=all, web and worker run in one process. To scale, run them separately with the same image and the same environment:
| Process | Setting | Notes |
|---|---|---|
| Web | MODE=web | Stateless, run as many as you like behind a load balancer |
| Worker | MODE=worker | Run as many as you like. Workers pick up jobs from PostgreSQL, so they need no load balancer |
From source, start a separate worker with pnpm dev:worker, or bundle it with pnpm build:worker and run node --env-file=.env worker.mjs. The worker doesn't read .env by itself - export the variables or pass --env-file. Set MODE=worker for it as well, otherwise Live Stream deliveries are marked delivered without reaching any client. pnpm dev:worker and pnpm sse-server run through npx tsx, which downloads tsx on first use.
With MODE=worker, Live Stream channels need the standalone SSE server (SSE_MODE=standalone), otherwise their deliveries fail. The standalone SSE server is not part of the Docker image - it runs from a source checkout with pnpm sse-server. See Streaming → Self-Hosting.
Health Check
GET /api/health returns {"status": "ok", "version": "..."}, or 503 with {"status": "error", "message": "database unreachable", "version": "..."} when PostgreSQL can't be reached. It needs no login. Use it for container health checks and load balancers.
Reverse Proxy & HTTPS
Run Alphorn behind a reverse proxy with TLS (Nginx, Caddy, Traefik, etc.) and set BETTER_AUTH_URL to the public https:// URL.
- HTTPS is required for anything but
localhost. The production build uses secure cookies, which browsers drop on plainhttp://- signing in overhttp://192.168.x.xfails. - Set
X-Forwarded-For(orX-Real-IP) to the real client IP. Alphorn uses it for login rate limiting and the per-IP stream limit. Without it, all clients share one limit. Make sure the proxy overwrites the header from clients, otherwise clients can fake it. - Disable buffering for streams so Live Stream events arrive immediately, and allow long-lived connections.
Example for Nginx:
server {
listen 443 ssl;
server_name alphorn.example.com;
# ssl_certificate ...;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/stream/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_buffering off;
proxy_read_timeout 86400;
}
}Alphorn already sends Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy headers. Because of X-Frame-Options: DENY, the dashboard can't be embedded in an iframe.
Database
Alphorn uses PostgreSQL exclusively - no Redis, no MongoDB, no additional infrastructure. All configuration, the delivery queue, and message history live in the database.
- Version: PostgreSQL 18 or newer. Alphorn uses the built-in
uuidv7()function added in PostgreSQL 18. - Permissions: the database user needs to create tables and the
pgbossschema (the queue creates it on first start), so give itCREATEon the database. - Extensions: the first migration runs
CREATE EXTENSION IF NOT EXISTS pg_trgm(for message search). The database user needs permission to create it, or create it upfront as a superuser. - Migrations: the Docker image applies them on every start. From source, run
pnpm db:deploy. - Connection poolers: the queue and the webhook cache use
LISTEN/NOTIFY, which doesn't work through PgBouncer in transaction mode. Alphorn still works, but falls back to polling and picks up webhook changes with up to 30 seconds delay. Connect directly or use session mode if you can.
Backups
Back up the database regularly, e.g. with pg_dump:
# Backup
docker compose exec -T db pg_dump -U alphorn -Fc alphorn > alphorn-$(date +%F).dump
# Restore into an empty database
docker compose exec -T db pg_restore -U alphorn -d alphorn --clean --if-exists < alphorn-2026-01-01.dumpBackups contain channel credentials (SMTP passwords, API tokens, webhook URLs) in plain text. Store them encrypted and with restricted access.
Updating
Prebuilt image
# Docker
docker compose pull
docker compose up -d
# Podman
podman compose pull
podman compose up -dDatabase migrations run automatically when the new container starts.
Available image tags:
| Tag | Description |
|---|---|
latest | Latest release |
1.2.3, 1.2 | A specific release, or the latest patch of a minor version |
edge | Latest build from the main branch (amd64 only) |
sha-<commit> | A specific commit (amd64 only for commits on main) |
Release tags are built for linux/amd64 and linux/arm64.
Pin a version tag in production so updates happen when you decide.
Repository compose file (built from source)
git pull
docker compose up -d --buildFrom source
git pull
pnpm install
pnpm db:deploy
pnpm build
# then restart the processGetting Started
Get started with Alphorn, the open-source notification router: create a webhook, send your first message, and route it to Slack, email, SMS, and more.
Webhooks
Receive notifications via HTTP webhooks - create a webhook, authenticate with an API key, and send JSON, Slack-compatible, or plain-text payloads.