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 initPrerequisites
- 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_CAThe 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 initFor 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:
| File | Purpose |
|---|---|
relayapi.selfhost.json | Domains, feature flags, immutable resource intent (including the Hyperdrive CA certificate ID), optional TikTok verified URL prefixes, and provisioned resource IDs |
relayapi.lock.json | Exact stable RelayAPI version and source repository |
.github/workflows/deploy-relayapi.yml | Migration-first production deployment |
.github/workflows/update-relayapi.yml | Daily stable-release update pull request |
.relayapi/secrets.env | Generated 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-deploymentThe 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--emailrequiresRESEND_API_KEY.--aienables the Workers AI binding.--downloaderrequiresDOWNLOADER_SERVICE_URLandDOWNLOADER_SERVICE_KEY.--media-processingprovisions 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 forceslinux/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:
| Connector | Connection-time verification | Lifecycle |
|---|---|---|
| Discord | Exact Discord webhook origin/path plus Discord's read-only Get Webhook with Token response | Reconnect after webhook deletion or token rotation |
| Slack/GovSlack | Exact Slack-issued webhook origin/path; Slack exposes no non-mutating probe | First publish verifies activation/channel policy; incoming webhooks expose no delete or message reconciliation |
| SMS | Active Twilio account plus an owned SMS-capable default sender | Reconnect 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
adwordsOAuth scope,GOOGLE_ADS_DEVELOPER_TOKEN, an authorized customer, Search/RSA options, and the explicit EU political-ad declaration. Manager access can use connection metadatalogin_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 needsr_ads_reporting. - Pinterest Ads v5 requires
ads:readandads:write; audience features remain production-approval-gated. - TikTok Marketing API v1.3 requires an authorized Business advertiser and creative identity.
TIKTOK_ADS_APP_IDandTIKTOK_ADS_APP_SECRETsupport 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 plandoctor 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 deployThe deploy is ordered for safety:
- Prove the complete PostgreSQL 18, extension-semantics, database-identity, and runtime no-DDL contract.
- Create or reuse the named Cloudflare resources in the configured immutable R2 jurisdiction.
- Apply R2 lifecycle, event-notification, and custom-domain policies.
- Download the exact stable source tag in
relayapi.lock.json. - Recheck extension replay capability, then verify and apply forward-only PostgreSQL migrations under the migration lock.
- Recheck the exact installed extension state and semantic/runtime-role contract.
- Idempotently create the initial administrator and organization.
- Build and deploy the API and dashboard Workers.
- Stream Worker secrets to Wrangler over stdin.
- 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
| Component | Cloudflare service | Purpose |
|---|---|---|
| API and dashboard | Workers | Public API and authenticated UI |
| API-key cache | KV | Edge authentication cache |
| Media, avatars, thumbnails, assets, rescue ledger | R2 | Durable object storage |
| PostgreSQL pool | Hyperdrive | Runtime database connections |
| Background work and dead letters | Queues | Publishing, sync, email, and recovery |
| Realtime coordinator | Durable Objects | WebSocket state |
| Image/video previews | Images and Media Transformations | Off-request thumbnails |
| Optional media processor | Container, Workflow, Queue, and DLQ | Private ffmpeg normalization, provider variants, custom covers, and bounded retry/dead-letter handling (MEDIA_PROCESSOR, MEDIA_PROCESSING_WORKFLOW, MEDIA_PROCESSING_QUEUE, MEDIA_PROCESSING_DLQ) |
| Optional generation | Workers AI | AI 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 upgradeupgrade 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.