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

Source: https://docs.alphorn.dev/self-hosting/

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:

```yaml title="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:
```

```bash
docker compose up -d
```

Alphorn is available at `http://localhost:3000`. Create your first account there.

<Callout type="warn">
PostgreSQL 18 images store data under `/var/lib/postgresql`. Mount the volume there, not at `/var/lib/postgresql/data` like older versions.
</Callout>

The repository's own [`docker-compose.yml`](https://github.com/alphorn-dev/alphorn/blob/main/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:

```bash
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`:

```bash
podman compose up -d
```

Or run the containers individually:

```bash
# 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.

```bash
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.

<Callout type="warn">
When running from source, migrations are **not** applied automatically. Run `pnpm db:deploy` after every update.
</Callout>

To run separate worker processes from source, see [Architecture & Scaling](#architecture--scaling).

## Environment Variables

The [`.env.example`](https://github.com/alphorn-dev/alphorn/blob/main/.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](/authentication#invite-only-mode-self-hosted) |

### 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](/organizations#by-shareable-link)
- SSO logins can't be linked to existing email/password accounts, because those email addresses are never verified (see [Authentication → SSO](/authentication#single-sign-on-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](#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](/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](https://github.com/stripe/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 |

<Callout type="warn">
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.
</Callout>

### 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`.

#### Google

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

<Callout type="warn" title="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.
</Callout>

#### 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](https://github.com/timgit/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.

<Callout type="warn">
With `MODE=worker`, [Live Stream](/streaming) 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](/streaming#self-hosting).
</Callout>

### 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:

```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`:

```bash
# 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
```

<Callout type="warn">
Backups contain channel credentials (SMTP passwords, API tokens, webhook URLs) in plain text. Store them encrypted and with restricted access.
</Callout>

## Updating

### Prebuilt image

```bash
# 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:

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

```bash
git pull
docker compose up -d --build
```

### From source

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