AlphornAlphorn Docs

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.

The fastest way to get Alphorn running is the prebuilt image from the GitHub Container Registry:

docker-compose.yml
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 -d

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

Podman

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

Or 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:latest

Alphorn 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 start

pnpm 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

VariableDescription
DATABASE_URLPostgreSQL connection string
BETTER_AUTH_SECRETSecret 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_URLPublic 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

VariableDefaultDescription
DISABLE_SIGNUPfalseSet 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.

VariableExampleDescription
SMTP_HOSTsmtp.example.comSMTP server
SMTP_PORT587465 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_USERUsername
SMTP_PASSPassword
SMTP_FROMAlphorn <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

VariableDefaultDescription
MODEallall (web + worker), web, or worker. Any other value stops the container with an error. See Architecture & Scaling
WORKER_CONCURRENCY20Deliveries a worker process sends in parallel
DATABASE_POOL_SIZE10Connection 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
PORT3000HTTP port of the web server

Limits

VariableDefaultDescription
WEBHOOK_MAX_BODY_BYTES1048576 (1 MiB)Larger webhook requests are rejected with 413
WEBHOOK_MAX_HOPS3How often a notification may be forwarded between Alphorn webhooks before it is rejected with 508
SSE_MAX_CONNECTIONS_PER_CHANNEL20Open stream connections per Live Stream channel
SSE_MAX_CONNECTIONS_PER_IP20Open stream connections per client IP

Live Stream (SSE)

VariableDefaultDescription
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_PORT4000Port of the SSE server

See Streaming → Self-Hosting for when you need this.

Logging & Error Tracking

VariableDefaultDescription
LOG_LEVELinfotrace, debug, info, warn, error, or fatal. Logs are JSON in production
BETTER_AUTH_LOG_LEVELwarnLog level of the auth library
SENTRY_DSN-Sentry or GlitchTip DSN. Warnings and errors from the logs are sent there too
SENTRY_ENVIRONMENTproductionEnvironment name in Sentry
SENTRY_TRACES_SAMPLE_RATE0.1Share 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

VariableDefaultDescription
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

VariableDescription
APP_VERSIONVersion 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

VariableDescription
GITHUB_CLIENT_IDOAuth App Client ID
GITHUB_CLIENT_SECRETOAuth 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.

Google

VariableDescription
GOOGLE_CLIENT_IDOAuth Client ID
GOOGLE_CLIENT_SECRETOAuth 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

VariableDescription
MICROSOFT_CLIENT_IDApplication (client) ID
MICROSOFT_CLIENT_SECRETClient secret value
MICROSOFT_TENANT_IDTenant 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.

VariableRequiredDescription
OIDC_CLIENT_IDYesOIDC Client ID (enables the provider)
OIDC_CLIENT_SECRETYesOIDC Client Secret
OIDC_DISCOVERY_URLRecommendedOIDC discovery endpoint (auto-configures URLs below)
OIDC_AUTHORIZATION_URLNoManual authorization endpoint (if not using discovery)
OIDC_TOKEN_URLNoManual token endpoint (if not using discovery)
OIDC_USERINFO_URLNoManual userinfo endpoint (if not using discovery)
OIDC_PROVIDER_IDNoCustom provider identifier (default: custom-oidc)
OIDC_PROVIDER_NAMENoDisplay name on the login page (default: SSO)
OIDC_SCOPESNoSpace-separated scopes (default: openid profile email)
OIDC_PKCENoEnable 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:

ProcessSettingNotes
WebMODE=webStateless, run as many as you like behind a load balancer
WorkerMODE=workerRun 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 plain http:// - signing in over http://192.168.x.x fails.
  • Set X-Forwarded-For (or X-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 pgboss schema (the queue creates it on first start), so give it CREATE on 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.dump

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

Database migrations run automatically when the new container starts.

Available image tags:

TagDescription
latestLatest release
1.2.3, 1.2A specific release, or the latest patch of a minor version
edgeLatest 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 --build

From source

git pull
pnpm install
pnpm db:deploy
pnpm build
# then restart the process

On this page