# Self-Hosted Deployment Guide

Source: https://wraps.dev/docs/guides/self-hosted

Guide

Deploy the full Wraps control plane to your own AWS account. Your API, your dashboard, your database — everything in your infrastructure with no Wraps servers in the critical path.

30–45 min

## Overview

Self-hosting deploys the full Wraps control plane — API Lambda, dashboard, SQS workers, DynamoDB, and EventBridge Scheduler — into your AWS account using [SST](https://sst.dev). You fork the Wraps repository, configure a handful of secrets, and run a single command. Everything runs in your account; no Wraps SaaS servers are in the critical path.

-   Fork-based deployment
    
    Fork the Wraps repo, run `pnpm selfhost:deploy`, and receive upstream updates on your schedule via a weekly sync PR
    
-   Control plane API on Lambda
    
    Deployed into your AWS account via SST — you own the function, logs, and billing
    
-   Your Postgres database
    
    Bring any Postgres-compatible DB (Neon, Supabase, Railway, self-hosted)
    
-   Dashboard on CloudFront
    
    The `apps/web` dashboard is deployed automatically via SST into your AWS account — no separate hosting needed
    
-   GitHub Actions OIDC for upgrades
    
    Automated upgrades use GitHub Actions OIDC to assume a short-lived AWS role — no stored keys required
    

One deploy path — from a fork, via SST

`pnpm selfhost:deploy` from a fork (this guide) deploys the full platform via SST: API Lambda, the dashboard on CloudFront, workers, DynamoDB, and EventBridge Scheduler.

An earlier API-only variant deployed by `wraps selfhost deploy` was removed in July 2026. The `wraps selfhost` CLI now only operates on an existing deployment — `selfhost login`, `selfhost connect`, `selfhost status`, `selfhost env`, `selfhost logs`.

One self-hosted plane per AWS account — the deploy creates the account-global `wraps-selfhost-scheduler-role`. If a previous deploy left that role behind, a fresh `pnpm selfhost:deploy` fails fast rather than colliding partway through.

## Prerequisites

-   Node.js 22+ and pnpm 10+ installed locally
-   AWS CLI configured with credentials that have IAM, Lambda, S3, CloudFormation, SSM, CloudFront, SQS, DynamoDB, EventBridge Scheduler, and CloudWatch Logs permissions
-   Postgres database connection string — [Neon](https://neon.tech), [Supabase](https://supabase.com), [Railway](https://railway.app), [PlanetScale for Postgres](https://planetscale.com/docs/postgres), or self-hosted Postgres all work. The connection URI must be the `postgresql://` form, and the role must be able to create schemas — migrations create a `drizzle` schema to track their own state, so a least-privilege application role usually needs `GRANT CREATE ON DATABASE` first
-   Wraps enterprise license key — contact [wraps.dev/contact](https://wraps.dev/contact) to get one
-   GitHub account for forking the repository

## 

1

Fork the Repository

The self-hosted deployment scripts live in the Wraps monorepo. Fork it so you can receive upstream updates on your own schedule.

1.  Fork on GitHub — go to `github.com/wraps-team/wraps` and click Fork. Keep the default branch name `main`.
2.  Clone your fork and install:
    
    Terminal
    
    ```
    git clone https://github.com/YOUR_ORG/wraps.gitcd wrapspnpm install
    ```
    

## 

2

Deploy the Control Plane

Run the deploy script from the root of your fork. It provisions all AWS infrastructure via SST, runs database migrations, and writes an `.env.selfhost` file with the outputs.

Terminal

```
# Pass all required values as flags (non-interactive):pnpm selfhost:deploy \  --database-url "postgres://user:pass@your-db.neon.tech/wraps" \  --license-key "wraps_lic_..." \  --yes# Or run without flags — the script will prompt you:pnpm selfhost:deploy
```

The script will:

1.  Check your AWS credentials
2.  Prompt for any missing values (database URL and license key) if not passed as flags
3.  Write `.env.selfhost` with auto-generated secrets
4.  Run `sst install` then deploy the Lambda, dashboard, and supporting AWS resources via SST
5.  Append the Lambda URL, app URL, and any email variables to `.env.selfhost`
6.  Apply all pending Drizzle migrations against your database — signup and login need these tables

If migrations fail, the infrastructure is still deployed

The deploy prints your API and app URLs, then reports what failed and why (rejected credentials, missing CREATE privilege, TLS, unreachable host). Fix the database and run `pnpm selfhost:upgrade` to retry — until migrations apply, creating an account will fail.

After the deploy completes, your `.env.selfhost` will look like this:

.env.selfhost

```
# Created automatically by pnpm selfhost:deployDATABASE_URL=postgres://user:pass@your-db.neon.tech/wrapsBETTER_AUTH_SECRET=<auto-generated>UNSUBSCRIBE_SECRET=<auto-generated>LICENSE_KEY=wraps_lic_...SELFHOST_AWS_REGION=us-east-1# Set when --web-domain flag is provided:SELFHOST_WEB_DOMAIN=dashboard.yourdomain.com# Populated after SST deploy completes:# Without --web-domain, NEXT_PUBLIC_APP_URL is a *.cloudfront.net URL:NEXT_PUBLIC_APP_URL=https://d1abc123xyz.cloudfront.netWRAPS_API_URL=https://abc123.lambda-url.us-east-1.on.awsBETTER_AUTH_URL=https://d1abc123xyz.cloudfront.net# Populated if email infrastructure was found:WRAPS_EMAIL_ROLE_ARN=arn:aws:iam::123456789012:role/wraps-email-roleAUTH_EMAIL_CONFIGURATION_SET=wraps-email-eventsAUTH_EMAIL_FROM=noreply@yourdomain.com
```

Optional flags

Pass `--web-domain` to set a custom domain for the dashboard (e.g. `dashboard.yourdomain.com`) and `--ai-provider` to enable AI email generation — with the Vercel AI Gateway, OpenAI, Anthropic, or AWS Bedrock, none of which is required for the rest of Wraps. See [Configuring AI](#configuring-ai) for the flags each one takes. Both can be added later by re-running `pnpm selfhost:upgrade`. Without `--web-domain`, `NEXT_PUBLIC_APP_URL` will be a `*.cloudfront.net` URL — a custom domain can be configured any time by re-running with `--web-domain`.

Pass `--sentry-dsn` to send this deployment's errors to your own Sentry project. Without it, a self-hosted stack reports errors nowhere — the dashboard's error page still shows an Error ID, but that ID only resolves in your own CloudWatch logs. Nothing is ever reported to Wraps either way. If you deploy or upgrade from GitHub Actions, add the same DSN as a `SENTRY_DSN` repository secret — the workflow rebuilds `.env.selfhost` from secrets on every run.

The DSN reaches the background workers too — the broadcast sender, the automation processor, the dead-letter consumers and the hourly sweeps. Those are the ones worth having: they swallow their own failures on purpose, so that one bad recipient cannot abort a broadcast and a dead-letter consumer never throws. A broken cron chain, a bookkeeping write that never landed, or an account whose IAM role has drifted is invisible without a DSN configured.

`--reroute-events` still works on both `selfhost:deploy` and `selfhost:upgrade`, but it is the legacy way to get SES events into a self-hosted control plane. New deployments should skip it — see SES event routing below.

Pass `--region` to deploy somewhere other than `us-east-1`. The value is persisted to `.env.selfhost` as `SELFHOST_AWS_REGION`, and every later `selfhost:upgrade` reads it back — so the region is set once, not re-passed. No file editing required.

### SES event routing

Registering this AWS account with your control plane (step 4 below) is what gets SES events to your self-hosted API. It adds a dedicated EventBridge target rather than repointing the existing one, so both control planes can receive the same events — run the plain `wraps platform connect` as well and app.wraps.dev stays live alongside your own dashboard. The full sequence:

1.  Writes the dedicated target and migrates any existing reroute:
    
    terminal.sh
    
    ```
    wraps selfhost connect
    ```
    
2.  Rebuilds EventBridge — nothing changes in AWS until this runs:
    
    terminal.sh
    
    ```
    wraps email upgrade
    ```
    
3.  Optional — also keep app.wraps.dev receiving events:
    
    terminal.sh
    
    ```
    wraps platform connect
    ```
    

Migrating off `--reroute-events`

The reroute is the legacy path, kept working for deployments that already use it. An account that went through it is migrated automatically the next time you run `wraps selfhost connect`: the CLI clears the old reroute and prints a warning, because two targets pointing at the same API would deliver every event twice.

The rebuild takes effect on the next `wraps email upgrade`, and it replaces rather than renames the EventBridge resources — expect a short gap in event delivery during that step.

## 

3

Deploy Email Infrastructure

Initialise email sending with AWS SES. This creates IAM roles, configures SES, and sets up event processing for open and click tracking.

terminal.sh

```
wraps email init
```

If you only need SMS, skip this step and run `wraps sms init` instead.

## 

4

Create Your Account and Connect

With the dashboard running, create your account, then authenticate the CLI against your instance and register the AWS connection.

1.  Open your dashboard URL and sign up with email and password.
2.  Create your organization — you will be prompted during onboarding. The CLI requires an organization to exist before it can connect.
3.  Sign in the CLI — reads your deployment metadata to find the dashboard URL, then opens a browser for device authorization against your own instance:

terminal.sh

```
wraps selfhost login
```

Why not `wraps auth login`?

The standard login command authenticates against the Wraps SaaS platform. `wraps selfhost login` reads your deployment metadata and authenticates against your own dashboard URL instead.

Once signed in, register this AWS account with your self-hosted control plane. This creates the `wraps-selfhost-console-access-role` IAM role — distinct from the platform's `wraps-console-access-role` because an IAM trust policy names one principal and your control plane is not the Wraps platform — and enables event streaming from your email infrastructure to the dashboard:

terminal.sh

```
wraps selfhost connect
```

Single-account deployments

The CLI automatically configures the trust policy to trust your own AWS account — not the Wraps SaaS platform account.

Three additional commands are available once you are signed in:

-   `wraps selfhost update-role` — refreshes the permissions on `wraps-selfhost-console-access-role` after an upgrade adds new ones. It only ever writes the self-hosted role; `wraps platform update-role` is the equivalent for the platform role and the two leave each other alone.
-   `wraps selfhost env` — prints the environment variables needed to deploy `apps/web` against your self-hosted control plane (useful for Vercel or any external hosting).
-   `wraps selfhost logout` — removes the local session token for your self-hosted instance.

## 

5

Verify Your Deployment

Check the health of your self-hosted control plane from the CLI:

terminal.sh

```
wraps selfhost status
```

A healthy deployment reports the API URL, app URL, AWS region, deployment timestamp, and license key prefix. Open your dashboard URL and confirm your connected AWS account and any email or SMS infrastructure are visible.

You are fully self-hosted

All API calls from the dashboard go directly to your Lambda. No Wraps SaaS servers are involved at runtime. Your license is verified offline — no phone-home required.

## Keeping Up to Date

New Wraps releases include Lambda updates, database migrations, and dashboard improvements. Choose the upgrade path that fits your workflow.

### Manual upgrade

Pull upstream changes into your fork, then run the upgrade script. It re-deploys the SST stack and runs any pending database migrations automatically.

Terminal

```
# Manual upgrade from your fork:pnpm selfhost:upgrade# Runs SST deploy + database migrations automatically
```

The upgrade script redeploys both the Lambda and the dashboard in a single run — no separate steps required.

### Automated upgrades with GitHub Actions

Your fork includes two pre-built workflows that handle the full update loop hands-free:

selfhost-sync.yml

Runs every Monday at 09:00 UTC (also manually triggerable). Fetches upstream `main`, opens a PR with up to 20 included commits. Any existing sync PR is automatically closed and superseded. You review and merge when ready.

selfhost-deploy.yml

Manual trigger only. Runs `pnpm selfhost:upgrade` (or `deploy` for first-time) with OIDC credentials — no stored AWS keys.

Follow these steps to enable them in your fork.

Actions setup 1 — Enable workflows in your fork

GitHub disables Actions on new forks by default. Go to your fork's Settings → Actions → General and set "Allow all actions and reusable workflows", then save.

The sync workflow needs one extra step: GitHub marks workflows with cron triggers as disabled in forks even after Actions is enabled, which blocks both the Monday schedule and manual "Run workflow" triggers. Open Actions → Sync from upstream and click Enable workflow on the banner — even if you only ever plan to sync manually. GitHub also suspends cron triggers after 60 days without repository activity; merging the sync PRs counts as activity and keeps it alive.

If a run sits at "Waiting for a runner to pick up this job", the workflows aren't the cause — both use standard GitHub-hosted runners. Check that your organization allows GitHub-hosted runners for this repository (org Settings → Actions) and, for private repositories, that your Actions minutes and spending limit under Settings → Billing aren't exhausted.

Actions setup 2 — Create an AWS OIDC identity provider for GitHub

See the [GitHub OIDC with AWS guide](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) and the [AWS IAM OIDC provider docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) for full context. In the AWS Console, go to IAM → Identity providers → Add provider:

-   Provider type: OpenID Connect
-   Provider URL: `https://token.actions.githubusercontent.com`
-   Audience: `sts.amazonaws.com`

Then create an IAM role named `wraps-github-deploy-role` (or similar) with the following trust policy. Replace `YOUR_ACCOUNT_ID`, `YOUR_GITHUB_ORG` and `YOUR_FORK_REPO` with your AWS account ID, the GitHub username or org that owns your fork, and your fork's repository name — which is not necessarily `wraps` if you renamed the fork:

github-trust-policy.json

```
{  "Version": "2012-10-17",  "Statement": [    {      "Effect": "Allow",      "Principal": {        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"      },      "Action": "sts:AssumeRoleWithWebIdentity",      "Condition": {        "StringEquals": {          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"        },        "StringLike": {          "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_ORG/YOUR_FORK_REPO:*"        }      }    }  ]}
```

Attach an IAM permissions policy to this role with the permissions SST needs: Lambda, S3, CloudFormation, IAM, SSM Parameter Store, CloudWatch Logs, STS, CloudFront, SQS, DynamoDB, and EventBridge Scheduler. Copy the role ARN — you will need it in the next step.

The deploy workflow already sets `permissions: id-token: write` at the workflow level, which is required for OIDC token exchange. You do not need to add this manually.

If the deploy fails with "Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity"

The role exists but its trust policy does not match the token GitHub sent. The two usual causes: the `sub` condition still says `/wraps` while your fork is renamed, or it pins a branch like `:ref:refs/heads/main`. The deploy job runs in the `production` environment, so GitHub issues the token with `repo:ORG/REPO:environment:production` as the subject — not the branch form. The `:*` suffix above matches both.

Actions setup 3 — Configure secrets and variables

In your fork, go to Settings → Secrets and variables → Actions and add the following:

After your first deploy

Open `.env.selfhost` and copy `BETTER_AUTH_SECRET` and `UNSUBSCRIBE_SECRET` into repository secrets. The upgrade workflow reconstructs `.env.selfhost` from secrets on every run — without these, SST will overwrite your Lambda with empty env vars.

Repository secrets

| Secret | Required | Description |
| --- | --- | --- |
| `AWS_DEPLOY_ROLE_ARN` | Always | ARN of the GitHub deploy role from step 2 |
| `DATABASE_URL` | Always | Postgres connection string — needed by SST on every deploy to set the Lambda env var |
| `WRAPS_LICENSE_KEY` | Always | Your Wraps license key — needed by SST on every deploy |
| `BETTER_AUTH_SECRET` | Always | Session signing secret — auto-generated on first deploy. Copy from `.env.selfhost` after initial deploy. Rotating this invalidates all active sessions |
| `UNSUBSCRIBE_SECRET` | Always | HMAC secret for unsubscribe links — auto-generated on first deploy. Copy from `.env.selfhost` after initial deploy |
| `WRAPS_EMAIL_ROLE_ARN` | Optional | IAM role ARN for SES sending. Copy from `.env.selfhost` after running `wraps email init` |
| `AUTH_EMAIL_FROM` | Optional | From address for transactional auth emails (password reset, magic links) |
| `AUTH_EMAIL_CONFIGURATION_SET` | Optional | SES configuration set for auth emails. Copy from `.env.selfhost` after running `wraps email init` |
| `AI_GATEWAY_API_KEY` | Optional | API key for the AI gateway — enables AI email template generation |
| `SENTRY_DSN` | Optional | DSN for _your_ Sentry project — turns on error reporting for the API, dashboard, browser and background workers. Set it here even if you passed `--sentry-dsn` locally: the upgrade rebuilds `.env.selfhost` from these secrets, so a DSN that is missing here is dropped on the next run |
| `DATABASE_POOL_MAX` | Optional | Database connections each function may hold. Defaults to `2`. Every function runs on Lambda with its own pool, so this is multiplied by concurrent containers and by the six functions in the stack — raising it is the fastest way to exhaust your database's connection slots. Only raise it if you have measured a reason to |

Repository variables (not secrets)

| Variable | Default | Description |
| --- | --- | --- |
| `AWS_REGION` | `us-east-1` | AWS region for deployments. The workflow writes it into the reconstructed `.env.selfhost` as `SELFHOST_AWS_REGION` — set it to the region you deployed into locally, or CI will target a different one |
| `SELFHOST_WEB_DOMAIN` | — | Custom domain for the dashboard (e.g. `dashboard.yourdomain.com`). When set, SST configures CloudFront with this domain |
| `NEXT_PUBLIC_APP_URL` | — | Full URL of the dashboard. Set this to your CloudFront URL (or custom domain) after first deploy — required for correct auth redirects on upgrades if not using `SELFHOST_WEB_DOMAIN`. Uploaded organization logos store this URL, so changing it later leaves existing logos pointing at the old domain — re-upload them, or keep the old domain resolving |

Actions setup 4 — Create a production environment (optional)

Go to Settings → Environments → New environment and create one named `production`. Enable Required reviewers and add yourself — this adds a manual approval step before any deploy job runs, giving you a second confirmation gate even after you click "Run workflow".

Actions setup 5 — How the workflows run

1.  1.Every Monday, **Sync from upstream** opens a PR with the latest commits from `wraps-team/wraps`. Review the changes and merge when you are ready.
2.  2.After merging, go to Actions → Deploy self-hosted → Run workflow. Select `upgrade` (or `deploy` for a first-time setup) and click Run.
3.  3.If you configured a `production` environment with required reviewers, approve the pending deployment. The job then assumes the OIDC role, runs SST deploy, and applies any pending migrations.
4.  4.The workflow redeploys the Lambda and dashboard together — once the job completes, your update is live.

## Configuring AI

Two dashboard features call a language model: AI email template generation and AI workflow generation. Everything else in Wraps works without any AI configuration — if you skip this section, those two features return an error and the rest of the dashboard is unaffected.

By default inference is routed through the Vercel AI Gateway, which needs a gateway account. Set `WRAPS_AI_PROVIDER` to call OpenAI, Anthropic, or AWS Bedrock directly instead. Every flag below works on both `pnpm selfhost:deploy` and `pnpm selfhost:upgrade`, so AI can be added, switched, or rotated at any time by re-running the upgrade.

Pick one

```
# Vercel AI Gateway (the default) — one key, routes to any upstream model.pnpm selfhost:upgrade --ai-gateway-api-key "vck_..."# OpenAI directly — no gateway account needed.pnpm selfhost:upgrade --ai-provider openai --openai-api-key "sk-..."# Anthropic directly.pnpm selfhost:upgrade --ai-provider anthropic --anthropic-api-key "sk-ant-..."# AWS Bedrock — no API key at all. Uses this deployment's own IAM role,# so inference bills to the AWS account already running the dashboard.pnpm selfhost:upgrade --ai-provider bedrock# Any OpenAI-compatible endpoint (LiteLLM, vLLM, a corporate proxy).pnpm selfhost:upgrade \  --ai-provider openai \  --openai-api-key "sk-..." \  --openai-base-url "https://llm.internal.example.com/v1"
```

The values are written to `.env.selfhost` and injected as Lambda environment variables, so you can also edit that file directly and re-run `pnpm selfhost:upgrade`.

### Choosing a model

You do not have to pick one. `AI_MODEL` is optional for every provider, and each one has a sensible default: Claude Sonnet 4 for the gateway, Anthropic, and Bedrock; GPT-5 for OpenAI.

To override it, pass `--ai-model` with either a catalog key — `claude-sonnet-4` or `gpt-5`, translated automatically to whatever id your provider expects — or a raw provider-native id, which is passed through untouched. Raw ids are the escape hatch for models newer than this release: `claude-opus-4-1-20250805` for Anthropic, `anthropic/claude-sonnet-4` for the gateway (gateway ids must be namespaced), a full inference profile ARN for Bedrock.

A model your provider cannot serve is caught at boot

Setting `AI_MODEL=gpt-5` while `WRAPS_AI_PROVIDER=anthropic` is a contradiction, and the dashboard reports it in its startup logs — naming the variable to fix — instead of leaving you to discover it when someone clicks Generate. The rest of the dashboard keeps working either way.

### AWS Bedrock, specifically

Bedrock is available on self-hosted deployments only, and it is the one option with no API key to manage. Inference is called with this deployment's own credentials — the dashboard's Lambda execution role, which the deploy already grants `bedrock:InvokeModel`. It never assumes a role into the AWS account you connected for SES, because that account is scoped for email and model spend does not belong on it.

Region resolution is `WRAPS_AI_REGION` first, then `AWS_REGION`, so you only need `--ai-region` when you want inference in a different region from the rest of the stack. Most Claude models on Bedrock are only reachable through a cross-region inference profile, so the region prefix (`us.`, `eu.`, `apac.`) is applied for you.

One step the deploy cannot do for you

Model access on Bedrock is a separate per-account, per-region opt-in in the AWS console under **Bedrock → Model access**. Until you enable the model there, calls fail with `AccessDeniedException` — which reads exactly like a credentials problem and sends you off to re-check IAM that was never wrong. IAM is already granted; if you see that error, it is almost certainly model access.

## .env.selfhost Reference

The deploy script creates and maintains `.env.selfhost` in your repo root. These values are read by SST at deploy time and injected as Lambda environment variables. If you upgrade via the GitHub Actions workflow, every value here must also be set as a repository secret or variable — the upgrade job rebuilds `.env.selfhost` from scratch on a fresh runner, so anything that only lives in your local file is dropped on the next automated upgrade.

| Variable | Required | Description |
| --- | --- | --- |
| `DATABASE_URL` | Yes | Postgres connection string for the control plane database. If your provider offers a connection pooler, use its pooled endpoint here — PlanetScale exposes PgBouncer on port `6432` and Neon offers a `-pooler` host. The same URL is used for migrations, and a transaction-mode pooler is fine for them: Drizzle takes no advisory locks and applies each run inside a single transaction |
| `LICENSE_KEY` | Yes | Your Wraps enterprise license key (`wraps_lic_...`) |
| `BETTER_AUTH_SECRET` | Yes | Secret for session signing — auto-generated on first deploy, do not rotate without invalidating all active sessions |
| `UNSUBSCRIBE_SECRET` | Yes | HMAC secret used to sign unsubscribe links in outbound emails — auto-generated on first deploy |
| `NEXT_PUBLIC_APP_URL` | Yes | Full URL of your dashboard (e.g. `https://dashboard.yourdomain.com`) — used for CORS and auth redirects. Set automatically after deploy |
| `WRAPS_API_URL` | Yes | Lambda function URL — set automatically after first deploy |
| `BETTER_AUTH_URL` | Yes | Same as `NEXT_PUBLIC_APP_URL` — set automatically after first deploy |
| `SELFHOST_AWS_REGION` | Yes | AWS region the SST stack deploys into — written on first deploy from `--region` (default `us-east-1`) and read back by every later upgrade. Changing it after deploy strands the existing stack in the old region |
| `SELFHOST_WEB_DOMAIN` | Optional | Custom domain for the dashboard — sets `AUTH_EMAIL_FROM` to `noreply@yourdomain.com` automatically. Pass via `--web-domain` flag |
| `WRAPS_EMAIL_ROLE_ARN` | Optional | IAM role ARN for SES sending — populated automatically when email infrastructure is present |
| `AUTH_EMAIL_FROM` | Optional | From address for transactional auth emails (password reset, magic links). Must be a verified SES sender |
| `AUTH_EMAIL_CONFIGURATION_SET` | Optional | SES configuration set name for auth emails — populated automatically when email infrastructure is present |
| `WRAPS_SSO_TRUSTED_ORIGINS` | Optional | Comma-separated identity-provider origins the SSO plugin is allowed to reach for OIDC discovery, e.g. `https://login.yourcompany.com,https://*.internal.yourcompany.com`. Okta, Entra ID, Google Workspace, Auth0, OneLogin, JumpCloud, Ping and WorkOS are trusted out of the box — set this for an Okta custom domain, a self-hosted Keycloak, or any other IdP. Saving an SSO provider whose issuer is not trusted fails with an untrusted-discovery-URL error |
| `AI_GATEWAY_API_KEY` | Optional | API key for the AI gateway — enables the AI email template generation feature. Pass via `--ai-gateway-api-key` flag |
| `WRAPS_AI_PROVIDER` | Optional | Which inference backend serves the template and workflow AI. Defaults to `gateway` (Vercel AI Gateway). Set `openai` to call OpenAI directly instead — no gateway account needed. Pass via `--ai-provider` on deploy or upgrade |
| `OPENAI_API_KEY` | Required with `openai` | Your OpenAI API key. Only read when `WRAPS_AI_PROVIDER=openai`; the gateway provider does not use it. Pair with `OPENAI_BASE_URL` to target an OpenAI-compatible endpoint such as a corporate proxy, LiteLLM, or vLLM |
| `ANTHROPIC_API_KEY` | Required with `anthropic` | Your Anthropic API key, for calling Anthropic directly without a gateway. Only read when `WRAPS_AI_PROVIDER=anthropic`; the gateway provider does not use it |
| `WRAPS_AI_REGION` | Optional | Region for `WRAPS_AI_PROVIDER=bedrock`, which is available on self-hosted deployments only. Falls back to `AWS_REGION`. Bedrock uses this deployment's own credentials, never a role in your SES-connected account, so inference bills to the account running the dashboard. One step the deploy cannot do for you: enable each model under **Bedrock → Model access** for that account and region. IAM is already granted, so an `AccessDeniedException` almost always means the model was never enabled |
| `SENTRY_DSN` | Optional | DSN for _your_ Sentry project — turns on error reporting for the API Lambda, the dashboard, and the background workers that send broadcasts and run automations (the browser copy, `NEXT_PUBLIC_SENTRY_DSN`, is derived from it at build time). Pass via `--sentry-dsn` on deploy or upgrade, or set a `SENTRY_DSN` repository secret for the Actions workflow. Unset means errors are reported nowhere |
| `AI_MODEL` | Optional | Override the model used for AI features. Accepts a catalog key, which is translated for whichever provider is active — `claude-sonnet-4` or `gpt-5` — or a raw provider-native id, which is passed through unchanged. Gateway ids must be namespaced (`anthropic/claude-sonnet-4`); a bare model name is only valid as a catalog key |
| `DATABASE_POOL_MAX` | Optional | Database connections each function may hold. Defaults to `2`. The stack runs six Lambda functions and each warm container keeps its own pool, so the real connection count is this number multiplied by containers and by functions. If you see `too many connections` or `remaining connection slots are reserved`, move `DATABASE_URL` to your provider's pooled endpoint before raising this |
| `DATABASE_DIRECT_URL` | Required if your database is behind a pooler | A **direct** (non-pooled) Postgres URL, used only by the index-creation scripts in `packages/db/scripts/`. Those run `CREATE INDEX CONCURRENTLY`, which a transaction-mode pooler cannot execute — PlanetScale PSBouncer on port `6432` and PgBouncer generally. Usually the same host on port `5432`. Leave unset if `DATABASE_URL` is already direct. Without it those scripts fail with `cannot run inside a transaction block`, and your database silently runs without the indexes that keep large broadcasts and the email log fast |
| `ALERT_EMAIL` | Optional | Email address for infrastructure alerts — subscribes to an SNS topic backing three CloudWatch alarms: one on messages landing in the batch dead-letter queue, one on the oldest message on the batch queue sitting for 15+ minutes (a broadcast is a self-propagating chain of one message at a time, so either signals a stuck send), and one on messages landing in the workflow dead-letter queue. AWS emails a subscription confirmation link to this address after deploy — accept it, or alarms fire with nowhere to deliver. Set it as a repository variable for Actions-driven upgrades, or the subscription is deleted on the next one and the alarms fire into an empty topic |
| `SELFHOST_ACCOUNT_HEALTH_ENABLED` | Optional | Set to `false` to disable the hourly SES account-health check. On by default: it assumes each connected AWS account's role and checks for sending paused, an SES enforcement status other than healthy, and bounce/complaint rates approaching AWS's thresholds — the kind of problem that otherwise surfaces only when AWS pauses the account mid-send |
| `SELFHOST_EVENT_FEED_STALENESS_ENABLED` | Optional | Set to `false` to disable the hourly check for SES event feeds that have gone silent while sends are still happening. On by default: without it, a broken event pipeline is invisible — sends keep going, the dashboard timeline freezes, and bounce/complaint handling goes blind, with no alert. The in-dashboard flag on the AWS account still appears either way. The check emails the org owner, and this requires `AUTH_EMAIL_FROM` to deliver: with it unset, the send falls back to an unverified `hello@wraps.dev` sender, SES rejects it, and the check silently retries every hour without ever reaching anyone |
| `SELFHOST_WORKFLOW_REAPER_ENABLED` | Optional | Set to `false` to disable the hourly sweep that fails workflow executions stuck past their scheduled resume. On by default: it is a backstop for lost EventBridge Scheduler deliveries — disabling it means a stuck execution stays stuck indefinitely |

## Next Steps

### Verify your domain

Add DKIM, SPF, and DMARC records to start sending from your own domain.

[Domain verification →](https://wraps.dev/docs/guides/domain-verification)

### Request production access

SES starts in sandbox mode. Request production access to send to any recipient.

[Production access →](https://wraps.dev/docs/guides/production-access)

### Enable AI features

Point Wraps at the Vercel AI Gateway, OpenAI, Anthropic, or AWS Bedrock, then run `pnpm selfhost:upgrade` to enable AI-powered email template and workflow generation.

[Configuring AI →](#configuring-ai)

### Tear down

Remove the SST-managed control plane (Lambda, CloudFront, S3, SQS, DynamoDB, EventBridge Scheduler) and clears local metadata. Email infrastructure created by `wraps email init` (SES config sets, IAM roles, EventBridge rules) is managed separately and must be removed via the CLI or AWS Console.

`pnpm selfhost:destroy`
