RelayAPI
Guides

Self-Hosting

Deploy and safely update RelayAPI in your own Cloudflare account.

Overview

RelayAPI's self-hosted edition keeps the same Cloudflare-native architecture as the hosted service. The supported setup is a bootstrap CLI, not a Docker image: Workers bindings such as Queues, Hyperdrive, R2, Images, Media Transformations, and Durable Objects do not have a faithful Docker Compose equivalent.

The CLI gives you a small private operator repository and provisions one RelayAPI instance in your Cloudflare account. It stores only resource IDs, domains, feature choices, and a reviewed release version in Git. Secrets remain in your shell, Cloudflare Worker secrets, and GitHub Actions secrets.

mkdir my-relayapi && cd my-relayapi
bunx @relayapi/self-host init

Prerequisites

  • Bun 1.2 or newer
  • A Cloudflare account with Workers, KV, R2, Queues, Hyperdrive, Images, Media Transformations, and Durable Objects enabled
  • A domain already active in that Cloudflare account
  • A managed PostgreSQL 18 database reachable by Hyperdrive
  • The PostgreSQL server CA bundle uploaded to Cloudflare for Hyperdrive, with its non-secret certificate UUID available
  • Separate PostgreSQL migration and runtime roles; the runtime role must not have DDL privileges
  • An R2 S3 API token for presigned browser uploads
  • Optional: the GitHub CLI (gh) when creating the private deployment repository automatically

The first version supports one managed RelayAPI instance per Cloudflare account. The CLI never deletes resources and has no destroy command.

1. Prepare credentials

Create a scoped Cloudflare API token with edit access to Workers scripts, routes, KV, R2, Queues, Hyperdrive, and the zone you will use. Export it and the account ID:

export CLOUDFLARE_API_TOKEN="..."
export CLOUDFLARE_ACCOUNT_ID="..."

Supply two TLS PostgreSQL URLs. The migration credential owns schema changes; the runtime credential is used by Hyperdrive and must not be able to run DDL. Both URLs must name the same host, port, and database, but authenticate as different roles. Remote URLs must use sslmode=verify-full.

export RELAYAPI_MIGRATION_DATABASE_URL='<tls-postgresql-url-for-migration-role>'
export RELAYAPI_RUNTIME_DATABASE_URL='<tls-postgresql-url-for-runtime-role>'

Upload the PostgreSQL server's CA certificate bundle to Cloudflare before initializing RelayAPI. Hyperdrive requires the resulting certificate UUID for verify-ca and verify-full; follow Cloudflare's TLS certificate instructions. The UUID is non-secret configuration. Do not put the CA private key in the operator repository.

npx wrangler cert upload certificate-authority \
  --ca-cert server-ca-chain.pem \
  --name RELAYAPI_DATABASE_CA

The PostgreSQL 18 server must provide the btree_gist, pg_trgm, and vector extension packages. Preflight proves the actual vector(1536), HNSW cosine operator class, trigram GIN operator class, and similarity operations inside a transaction that rolls back. It also connects with the runtime URL and proves that credential cannot create a table in public; seeing extension names in a provider catalog is not enough.

Current fresh-install release stop: do not replay the immutable 0000_baseline.sql directly against a virgin PostgreSQL 18 database. Its sealed expression-index SQL does not parse on PostgreSQL 18.4. A fresh installation must use RelayAPI's protected generation-2 baseline process; if the reviewed release bundle cannot provide and verify that process, stop before migration or Worker deployment. Existing installations with a valid baseline continue through the normal forward-only migration path.

Create an R2 S3 API token in the Cloudflare dashboard and export its access key pair:

export R2_ACCESS_KEY_ID="..."
export R2_SECRET_ACCESS_KEY="..."

init generates the encryption key, Better Auth secret, and a strong initial administrator password in a local, mode-0600, gitignored file. You may instead provide your own values:

ENCRYPTION_ACTIVE_KEY="$(openssl rand -hex 32)"
ENCRYPTION_IDENTITY_KEY="$(openssl rand -hex 32)"
export ENCRYPTION_KEY="active=${ENCRYPTION_ACTIVE_KEY},identity=${ENCRYPTION_IDENTITY_KEY}"
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
export RELAYAPI_ADMIN_EMAIL="you@example.com"
export RELAYAPI_ADMIN_PASSWORD="$(openssl rand -base64 24)"

The two encryption entries must contain distinct 32-byte values. Keep the identity entry unchanged and non-active across every future key rotation; prepend a new active key and retain old decryption keys until their ciphertext has been rotated. Both doctor and deploy reject a missing, active, duplicate, or reused identity key before contacting PostgreSQL or Cloudflare.

2. Initialize the operator repository

Run the interactive wizard:

bunx @relayapi/self-host init

For automation, provide the non-secret values explicitly:

bunx @relayapi/self-host init \
  --non-interactive \
  --account-id "$CLOUDFLARE_ACCOUNT_ID" \
  --zone-id "<ZONE_ID>" \
  --domain "example.com" \
  --admin-email "you@example.com" \
  --hyperdrive-ca-certificate-id "<CLOUDFLARE_CA_CERTIFICATE_UUID>"

This creates:

FilePurpose
relayapi.selfhost.jsonDomains, feature flags, immutable resource intent (including the Hyperdrive CA certificate ID), optional TikTok verified URL prefixes, and provisioned resource IDs
relayapi.lock.jsonExact stable RelayAPI version and source repository
.github/workflows/deploy-relayapi.ymlMigration-first production deployment
.github/workflows/update-relayapi.ymlDaily stable-release update pull request
.relayapi/secrets.envGenerated local secrets; mode 0600 and gitignored

To create and push a private GitHub operator repository in the same command, authenticate gh first and add --github:

gh auth login
bunx @relayapi/self-host init --github your-org/relayapi-deployment

The CLI uploads environment values as GitHub secrets over stdin. It never puts secret values in command arguments or the checked-in configuration. Run this --github form only in a new empty directory: the CLI refuses an existing .git path or any pre-existing entry, even with --force, before changing Git state. Omit --github for collision-safe local initialization in an existing directory.

3. Choose optional features

The core install includes the API, dashboard, publishing, inbox, automations, analytics, media, and realtime features. Self-hosted community mode does not use RelayAPI's Stripe billing and does not impose hosted-plan entitlements.

Optional integrations are explicit flags during init:

bunx @relayapi/self-host init --email --ai --downloader --media-processing
  • --email requires RESEND_API_KEY.
  • --ai enables the Workers AI binding.
  • --downloader requires DOWNLOADER_SERVICE_URL and DOWNLOADER_SERVICE_KEY.
  • --media-processing provisions Cloudflare Containers and Workflows for automatic compression, provider variants, and custom covers. It is optional; originals remain uploadable and publishable when it is disabled. Docker must be running during deployment. On Apple Silicon, Docker's amd64 emulation must be enabled; the CLI forces linux/amd64, the architecture Cloudflare runs.

Media upload sessions themselves are part of the base installation and accept direct-to-storage objects up to 200 MiB. Files greater than 64 MiB use 16 MiB multipart parts; the Worker-proxy upload endpoint remains capped at 50 MiB. Provisioning pins media-bucket browser CORS to the configured dashboard origin, permits PUT, and exposes ETag so multipart completion works without granting unrelated origins access. Ready media returns a stable reference_url for post attachments separately from its potentially expiring read url. With media processing enabled, ready image/video/audio uploads enqueue best-effort normalization. Processing is fail-open: the original stays ready, and publishing uses a compatible normalized derivative only when it is ready, unexpired, and smaller. See Media Uploads for the session and custom-cover flows.

OAuth platform credentials are also optional. Export only the platforms you intend to expose, using the names in .env.example, then run deploy again. Configure each provider's callback URL against your API hostname.

Publisher connector compatibility

The shared database schema now contains all 22 publishing platform values, including slack. Version-pinned self-host upgrades apply the PostgreSQL enum migration before either Worker is deployed, so an older database is ready before the Slack route can create an account.

Discord, Slack, and Twilio SMS are tenant-supplied credential connections. They reuse the existing ENCRYPTION_KEY account-credential store and require no new self-host binding or global provider secret:

ConnectorConnection-time verificationLifecycle
DiscordExact Discord webhook origin/path plus Discord's read-only Get Webhook with Token responseReconnect after webhook deletion or token rotation
Slack/GovSlackExact Slack-issued webhook origin/path; Slack exposes no non-mutating probeFirst publish verifies activation/channel policy; incoming webhooks expose no delete or message reconciliation
SMSActive Twilio account plus an owned SMS-capable default senderReconnect after Twilio Auth Token rotation

Discord forum/media-thread editing is self-host compatible without another binding, secret, or database migration. The publisher journals the confirmed thread snowflake in the existing target provider_effects JSON as a discord_thread_context effect, and persisted inbox messages retain the same exact thread context. If a record is known to be thread-scoped but lacks a valid durable thread ID, the edit fails closed with DISCORD_THREAD_CONTEXT_MISSING before provider I/O. Upgrade through the version-pinned self-host release so the publisher and edit routes move together.

New Mastodon connections register an OAuth client dynamically on the validated public instance and pin that origin to the encrypted account. The optional MASTODON_CLIENT_ID/MASTODON_CLIENT_SECRET declarations remain only for legacy direct-code compatibility; the recommended per-instance start flow does not use them. Snapchat still requires an allowlisted SNAPCHAT_CLIENT_ID/SNAPCHAT_CLIENT_SECRET. Telegram continues to require the operator's existing TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET; tenant users connect chats through the same managed-bot permission challenge rather than submitting those secrets.

Connection-owned provider authority is identical in hosted and self-hosted deployments. Generic account metadata updates cannot overwrite provider origins or identities such as Mastodon/Listmonk instance_url, Bluesky pds_url/did/auth_mode, WhatsApp waba_id, Beehiiv publication_id, Twilio from_number/MMS capability, TikTok verified prefixes, or Snapchat's internal Public Profile verification marker. Beehiiv publishing uses the connected publication ID; SMS always uses the connector-verified sender and treats a legacy target_options.from_number only as a matching assertion; Listmonk publishing uses the connected canonical public HTTPS base and applies DNS/private-host rejection, redirect denial, a 30-second through-body timeout, and a 512 KiB response cap. Newsletter list/template discovery also checks the account's workspace before provider I/O. These guards reuse the existing Worker, database, DNS, and encryption paths and add no operator secret or binding.

After upgrading, reconnect every Snapchat account created before the Public Profile verification marker was introduced. Such legacy records fail closed with SNAPCHAT_RECONNECT_REQUIRED; a metadata edit cannot migrate or forge the provider identity.

LinkedIn publishing likewise always uses the member or organization URN selected by the connector. Legacy target_options.linkedin.organization_urn is only a matching assertion and a mismatch is rejected before provider I/O. No self-host setting is required.

TikTok Direct Post can use bounded FILE_UPLOAD for videos without additional configuration. Photo posts and explicit video PULL_FROM_URL require URL prefixes that are already verified in the operator's TikTok developer app. Add the same non-secret prefixes to relayapi.selfhost.json before connecting the TikTok account:

{
  "publishing": {
    "tiktokVerifiedUrlPrefixes": [
      "https://media.example.com/tiktok/"
    ]
  }
}

The CLI validates the prefixes and publishes them to the API Worker as TIKTOK_VERIFIED_URL_PREFIXES. OAuth snapshots that list into immutable account metadata; changing the operator config does not expand an existing connection, so reconnect TikTok to adopt the new list. Prefixes are public authority configuration, not secrets. They must use HTTPS and contain no credentials, IP host, non-default port, query string, or fragment, and each path must end in /. That slash is an authority boundary: /media/ does not permit /mediaevil.

Advertising provider compatibility

Self-hosted releases ship the same six registered ad adapters and durable provider-first operation fences as the hosted API. Community mode bypasses only RelayAPI's hosted Stripe entitlement check; it never bypasses actor permissions, the dedicated ad_connections credential, provider-account scope, revocation, provider approval, or the per-operation capability returned by GET /v1/ads/platforms.

The five non-Meta write adapters require no additional Cloudflare binding, but their provider credentials and options remain mandatory:

  • Google Ads v25 requires the adwords OAuth scope, GOOGLE_ADS_DEVELOPER_TOKEN, an authorized customer, Search/RSA options, and the explicit EU political-ad declaration. Manager access can use connection metadata login_customer_id.
  • LinkedIn Marketing API requires approved Advertising API access, rw_ads, an eligible account/content role, existing content authority, typed targeting, and explicit political intent. Reporting separately needs r_ads_reporting.
  • Pinterest Ads v5 requires ads:read and ads:write; audience features remain production-approval-gated.
  • TikTok Marketing API v1.3 requires an authorized Business advertiser and creative identity. TIKTOK_ADS_APP_ID and TIKTOK_ADS_APP_SECRET support an operator-run upstream OAuth exchange; a publishing Login Kit token is not an ads credential.
  • X Ads API v12 requires Ads entitlement, OAuth 1.0a token_secret, TWITTER_ADS_CONSUMER_KEY, TWITTER_ADS_CONSUMER_SECRET, and a funding instrument.

The presence of an environment secret does not turn an unsupported or approval-gated operation into a supported one. Use the advertising capability guide and runtime response before exposing a write in an operator UI.

4. Inspect and deploy

Run the preflight before making changes:

bunx @relayapi/self-host doctor
bunx @relayapi/self-host plan

doctor verifies the config and release lock, required tools, separate database roles, PostgreSQL 18 semantic capabilities and runtime no-DDL boundary, encryption-key format, Cloudflare account/zone access, and permission to inspect every required service in the configured R2 jurisdiction. plan reports which resources will be created or reused.

Hyperdrive creation always sends the configured CA certificate ID, and an existing pinned Hyperdrive must retain that exact trust anchor. A conflicting ID without an explicit rotation flag, or a Hyperdrive with no attached CA certificate, fails closed. Older operator configs without the field remain readable: the next successful real configure or deploy adopts the CA ID from the exact existing Hyperdrive and writes it to relayapi.selfhost.json. A legacy config must persist that attached CA before requesting a different rotation target; if the requested target is already attached, it can be safely adopted immediately. plan, doctor, and dry runs remain read-only, while a clean installation always requires the explicit ID.

Rotate the Hyperdrive server CA

Upload the replacement regional CA bundle, retain the returned UUID, and pass that UUID explicitly rather than editing the operator config first:

bunx @relayapi/self-host plan \
  --hyperdrive-ca-certificate-id "<NEW_CLOUDFLARE_CA_CERTIFICATE_UUID>"
bunx @relayapi/self-host doctor \
  --hyperdrive-ca-certificate-id "<NEW_CLOUDFLARE_CA_CERTIFICATE_UUID>"
bunx @relayapi/self-host deploy \
  --hyperdrive-ca-certificate-id "<NEW_CLOUDFLARE_CA_CERTIFICATE_UUID>"

The preview reports rotate, retain, or legacy adopt intent. Rotation is allowed only for the exact ID already pinned as resources.hyperdriveId, never for a resource found only by name. The PATCH preserves any client certificate and origin connection limit, verifies the returned and freshly read CA UUID, then persists the new UUID only after Cloudflare reconciliation succeeds. Dry runs and failed PATCHes leave relayapi.selfhost.json unchanged. Rerunning the same command is safe if Cloudflare already reached the target but local writing was interrupted; an unexplained third UUID fails closed.

Cloudflare performs a fresh database connection and an empty PostgreSQL test query before accepting connection-parameter updates. deploy additionally probes both deployed Workers through their actual Hyperdrive bindings, so no undocumented connection-pool restart is used. See Cloudflare's PATCH API and configuration troubleshooting.

Choose the R2 jurisdiction during init with --r2-jurisdiction default|eu. The CLI records that choice in relayapi.selfhost.json, sends it on every R2 inspection and apply request, and refuses a same-named bucket in the other jurisdiction. Cloudflare does not permit changing a bucket's jurisdiction after creation.

Deploy the reviewed version:

bunx @relayapi/self-host deploy

The deploy is ordered for safety:

  1. Prove the complete PostgreSQL 18, extension-semantics, database-identity, and runtime no-DDL contract.
  2. Create or reuse the named Cloudflare resources in the configured immutable R2 jurisdiction.
  3. Apply R2 lifecycle, event-notification, and custom-domain policies.
  4. Download the exact stable source tag in relayapi.lock.json.
  5. Recheck extension replay capability, then verify and apply forward-only PostgreSQL migrations under the migration lock.
  6. Recheck the exact installed extension state and semantic/runtime-role contract.
  7. Idempotently create the initial administrator and organization.
  8. Build and deploy the API and dashboard Workers.
  9. Stream Worker secrets to Wrangler over stdin.
  10. Probe each deployed Worker's actual Hyperdrive binding and require the expected database identity, ok: true, an open runtime-control state, and matching application/configured baseline generations before reporting the deployment live.

If any migration fails, the Worker deploy does not start. Migrations are never automatically rolled back after a forward migration.

Resources created

ComponentCloudflare servicePurpose
API and dashboardWorkersPublic API and authenticated UI
API-key cacheKVEdge authentication cache
Media, avatars, thumbnails, assets, rescue ledgerR2Durable object storage
PostgreSQL poolHyperdriveRuntime database connections
Background work and dead lettersQueuesPublishing, sync, email, and recovery
Realtime coordinatorDurable ObjectsWebSocket state
Image/video previewsImages and Media TransformationsOff-request thumbnails
Optional media processorContainer, Workflow, Queue, and DLQPrivate ffmpeg normalization, provider variants, custom covers, and bounded retry/dead-letter handling (MEDIA_PROCESSOR, MEDIA_PROCESSING_WORKFLOW, MEDIA_PROCESSING_QUEUE, MEDIA_PROCESSING_DLQ)
Optional generationWorkers AIAI tools when enabled

Every resource uses the relayapi-selfhost-* prefix. If a unique named resource already exists, the CLI reuses it; if duplicates make ownership ambiguous, it stops instead of guessing.

Receiving updates

Self-hosted deployments follow stable self-host-vX.Y.Z releases, never the main branch. The generated update workflow checks daily and opens a pull request that changes only relayapi.lock.json.

Review and merge that pull request to deploy the update. The merge triggers the same migration-first workflow as the initial deployment, so application code is not updated if its database migration cannot complete.

You can check manually too:

bunx @relayapi/self-host upgrade

upgrade updates the local lock file but does not deploy. Review the diff, then run deploy or merge the generated pull request. Keep backups and restore from a backup for database rollback; the CLI deliberately does not attempt destructive down-migrations.

Troubleshooting

A service is not enabled

Open that product once in the Cloudflare dashboard and complete any account activation or billing prompt, then rerun doctor. The CLI will not silently replace an unavailable Cloudflare product with a different architecture.

Hyperdrive cannot connect

Confirm the runtime URL is reachable from Cloudflare, uses TLS, and contains a direct PostgreSQL hostname rather than a transaction-pool endpoint. The runtime role needs normal DML privileges but must not own schemas or tables. Also verify that cloudflare.hyperdriveCaCertificateId is the UUID of the CA bundle actually attached to the pinned Hyperdrive; the CLI will not silently replace a different trust anchor unless you pass the explicit rotation flag for that exact pinned resource.

A deployment update is stuck

Open the GitHub Actions run. Migration verification and migration application happen before either Worker deploy, and the logs identify the failed phase without printing connection URLs or Worker secrets.

A platform is unavailable

Only platforms with their required OAuth credentials are connectable. Add the provider credentials as GitHub secrets, configure the provider's callback URLs for your custom API/app domains, and rerun the deploy workflow.

Found something wrong? Help us improve this page.

On this page