Skip to content

Deployment

This document is a practical guide to putting a Fedify application into production. It is written primarily for readers who have already built something with Fedify locally and are about to deploy it for the first time—and secondarily for readers who deploy web applications routinely but have not worked with Fedify or the fediverse before.

It does not retread general web-application deployment advice that is already well-covered elsewhere. Instead, it focuses on the choices and pitfalls that are specific to Fedify and to ActivityPub: how to pin your canonical origin, where to terminate TLS, how to keep inbox delivery healthy at scale, how to sanitize federated HTML, how to defend the fetches Fedify can't protect for you, and how to retire a server without orphaning every remote follower.

The document assumes you already have a working Fedify app. If you are still getting started, read the Manual chapters first, then come back here when you are ready to ship.

Choosing a JavaScript runtime

Fedify supports Deno, Node.js, and Bun as first-class runtimes, and has dedicated support for Cloudflare Workers. The choice matters for production.

Node.js
The default recommendation. Mature, widely deployed, extensive package ecosystem, well-understood operational tooling. Node.js does not provide a built-in HTTP server that accepts fetch()-style handlers, so you will need an HTTP adapter such as @hono/node-server or srvx.
Deno
A strong choice if you prefer TypeScript-first tooling, a built-in HTTP server (Deno.serve()), permission-based sandboxing, and native OpenTelemetry support enabled with OTEL_DENO=1 (stable since Deno 2.4). If you are already comfortable with Deno, there is no reason to switch to Node.js for production.
Bun
Bun runs Fedify, but has known memory-leak issues that make it a difficult recommendation for long-running production workloads at the time of writing. A common compromise is to develop on Bun (for its fast install and test loops) and deploy on Node.js, since both consume the same npm-style package sources. Revisit Bun for production once the memory profile stabilizes for your workload.
Cloudflare Workers
A cost-effective option for servers that do not need persistent long-running processes. Workers impose architectural constraints—no global mutable state across requests, bindings-only access to queues and KV, execution time limits—that Fedify accommodates through a dedicated builder pattern and the @fedify/cfworkers package.

The rest of this guide assumes a traditional server environment (Node.js or Deno) unless noted otherwise.

Configuration fundamentals

These decisions apply to every deployment target. Make them once, early, and document them—changing them after launch ranges from painful to impossible (see Domain name permanence).

Canonical origin

Pin your canonical origin explicitly with the origin option unless you are intentionally hosting multiple domains on the same Fedify instance:

typescript
import { 
createFederation
} from "@fedify/fedify";
const
federation
=
createFederation
<void>({
origin
: "https://example.com",
// Other options... });

When origin is set, Fedify constructs actor URIs, activity IDs, and collection URLs using the canonical origin rather than the origin derived from the incoming Host header. Without this option, an attacker who bypasses your reverse proxy and hits the upstream directly can coerce Fedify into constructing URLs with the upstream's address—leaking infrastructure details and potentially producing activities that other fediverse servers reject or cache under the wrong identity.

See Explicitly setting the canonical origin for the full API, including the handleHost/webOrigin split for separating WebFinger handles from the server origin.

Behind a reverse proxy

If you cannot pin a single canonical origin—typically because you host multiple domains on the same process—the alternative is to trust your reverse proxy's forwarded headers and let Fedify reconstruct the request URL from them. The x-forwarded-fetch package does exactly this: it rewrites the incoming Request so that request.url reflects the X-Forwarded-Host, X-Forwarded-Proto, and related headers instead of the internal address the proxy connected on.

On Node.js with Hono or similar frameworks, wrap your handler:

typescript
import { serve } from "@hono/node-server";
import { behindProxy } from "x-forwarded-fetch";

serve({
  fetch: process.env.BEHIND_PROXY === "true"
    ? behindProxy(app.fetch.bind(app))
    : app.fetch.bind(app),
  port: 3000,
});

On Deno with Fresh (or anywhere you have direct access to a middleware chain), call getXForwardedRequest():

typescript
import { getXForwardedRequest } from "@hongminhee/x-forwarded-fetch";

app.use(async (ctx) => {
  if (Deno.env.get("BEHIND_PROXY") === "true") {
    ctx.req = await getXForwardedRequest(ctx.req);
    ctx.url = new URL(ctx.req.url);
  }
  return await ctx.next();
});

WARNING

Only enable x-forwarded-fetch when you actually sit behind a proxy you control. If the process is ever reachable directly from the public internet, a malicious client can spoof X-Forwarded-Host and impersonate any hostname. The common pattern is to gate the middleware behind a BEHIND_PROXY=true environment variable and set it only in the deployment that runs behind your proxy.

If you can pin a canonical origin, prefer that over x-forwarded-fetch—it is the simpler, safer default.

Persistent KV store and message queue

The in-memory defaults are for development only. MemoryKvStore loses data on restart, and InProcessMessageQueue loses every in-flight activity; neither survives horizontal scaling. Pick a persistent backend before you take traffic:

BackendKV storeMessage queueWhen to choose
PostgreSQLPostgresKvStorePostgresMessageQueueYou already run Postgres for app data
RedisRedisKvStoreRedisMessageQueueDedicated cache/queue infrastructure
MySQL/MariaDBMysqlKvStoreMysqlMessageQueueYou already run MySQL or MariaDB
SQLiteSqliteKvStoreSqliteMessageQueueSingle-node/embedded deployments
RabbitMQAmqpMessageQueueExisting AMQP infrastructure
Deno KVDenoKvStoreDenoKvMessageQueueDeno Deploy
Cloudflare KV + QueuesWorkersKvStoreWorkersMessageQueueCloudflare Workers

See the Key–value store and Message queue chapters for setup details and trade-offs between backends (connection pooling, ordering guarantees, native retry support).

A reasonable default if you have no prior preference: PostgreSQL for both KV and MQ, on the same database you already use for your application data. Single operational surface, one backup strategy, one set of metrics.

Actor key lifecycle

Actor key pairs must be generated once per actor and stored durably— typically in the same row as the actor record itself. Do not regenerate them on startup or during deploys. Other fediverse servers cache your public keys (often for hours or days), and a key rotation they don't know about will cause every incoming signature verification to fail against the cached key and every outgoing activity you sign with the new key to be rejected. The symptoms—silent federation breakage with no clear error—are among the most frustrating to diagnose after the fact.

Keep two distinct categories of secrets separate:

  • Instance-wide secrets (session secret, instance actor private key, database credentials) live in environment variables or a secret manager. See Secret and key management.
  • Per-actor key pairs live in the database, one pair per actor, created at registration time.

If you need to rotate a compromised key, use the Update activity to announce the new key and keep serving both the old and new keys from the actor document for a transition window. Fediverse clients will eventually pick up the new one as caches expire.

Traditional deployments

The most common way to run a Fedify application in production is as a long-lived Node.js or Deno process, managed by a service supervisor, behind a reverse proxy that terminates TLS. The pieces are deliberately boring: everything in this section predates the fediverse by decades and is thoroughly documented by its upstream projects. This section focuses on the details that matter specifically for Fedify.

Running the process

Node.js needs an adapter because it has no built-in fetch()-style HTTP server. @hono/node-server is the most common choice; srvx is a universal alternative that also works unchanged on Deno and Bun:

typescript
import { 
serve
} from "@hono/node-server";
import {
createFederation
} from "@fedify/fedify";
const
federation
=
createFederation
<void>({
// Configuration... });
serve
({
fetch
: (
request
) =>
federation
.
fetch
(
request
, {
contextData
:
undefined
}),
hostname
: "127.0.0.1",
port
: 3000,
});
typescript
import { 
serve
} from "srvx";
import {
createFederation
} from "@fedify/fedify";
const
federation
=
createFederation
<void>({
// Configuration... });
serve
({
fetch
: (
request
) =>
federation
.
fetch
(
request
, {
contextData
:
undefined
}),
hostname
: "127.0.0.1",
port
: 3000,
});

Deno ships a native server; export a default object with a fetch method and launch it with deno serve:

index.ts
typescript
import { 
createFederation
} from "@fedify/fedify";
const
federation
=
createFederation
<void>({
// Configuration... }); export default {
fetch
(
request
:
Request
):
Promise
<Response> {
return
federation
.
fetch
(
request
, {
contextData
:
undefined
});
}, };
bash
deno serve --host 127.0.0.1 index.ts

For framework integration patterns (Hono, Express, Fresh, SvelteKit, and others), see the Integration chapter.

Running under systemd

A Fedify process that exits unexpectedly—for any reason, from an OOM kill to an unhandled rejection—must be restarted automatically, or federation stalls silently while outgoing activities pile up and remote servers time out waiting for responses. On Linux, systemd is the standard way to do this.

A minimal service unit for a Node.js-based Fedify application might look like:

/etc/systemd/system/fedify.service
ini
[Unit]
Description=Fedify application
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=fedify
Group=fedify
WorkingDirectory=/srv/fedify
EnvironmentFile=/etc/fedify/env
ExecStart=/usr/bin/node --enable-source-maps dist/server.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/fedify

[Install]
WantedBy=multi-user.target

The EnvironmentFile= path should be chmod 600 and owned by root:root (or by the service user)—it contains database passwords, session secrets, and similar material that must not be world-readable. Keep the actual application code in a path the service user cannot modify (ProtectSystem=strict enforces this for system directories; the ReadWritePaths= list is for any local storage you do need).

Enable and start the unit:

bash
systemctl enable --now fedify.service
journalctl -u fedify.service -f

On Deno, replace the ExecStart= line with /usr/bin/deno serve --allow-net --allow-env --allow-read=/srv/fedify --allow-write=/var/lib/fedify /srv/fedify/index.ts (tighten the permission flags to the minimum your app needs—that's the point of running on Deno).

If you plan to split web traffic from background queue processing into separate processes, see Separating web and worker nodes below; you will typically run two service units (or a templated fedify@.service instantiated twice) rather than one.

Process managers: PM2 and friends

PM2 and similar Node.js process managers work with Fedify, but they duplicate responsibilities that systemd already handles on any Linux server—respawn, log rotation, resource limits—and they don't integrate with the rest of your system supervision. Prefer systemd on Linux. PM2 is reasonable on platforms without systemd, or when you're deploying to a shared host where you don't control PID 1.

Reverse proxy

Run your Fedify process on a loopback port (for example, 127.0.0.1:3000) and put a reverse proxy in front of it. The proxy handles TLS termination, HTTP/2 (and increasingly HTTP/3), static asset caching, and—importantly for ActivityPub—shielding your upstream from direct traffic so that the canonical origin guarantee holds.

Nginx

nginx
server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name example.com;

  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

  # Fedify emits activity payloads that can exceed nginx's 1 MiB default,
  # especially collections with many inline items.
  client_max_body_size 10m;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;

    # Pass the original Host so Fedify (or x-forwarded-fetch) can reconstruct
    # the public URL.  If you set `origin` explicitly, these are used only
    # for logging, but they should still be correct.
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP         $remote_addr;

    # Inbox deliveries from slow remote servers and long-running queue
    # operations can exceed nginx's 60s default.
    proxy_read_timeout  120s;
    proxy_send_timeout  120s;
  }
}

# Redirect plain HTTP to HTTPS; ActivityPub assumes HTTPS throughout.
server {
  listen 80;
  listen [::]:80;
  server_name example.com;
  return 301 https://$host$request_uri;
}

Caddy

Caddy's defaults suit Fedify well: automatic HTTPS from Let's Encrypt, HTTP/2 and HTTP/3 on by default, and forwarded headers set correctly out of the box. A full configuration fits on one line:

caddy
example.com {
  reverse_proxy 127.0.0.1:3000
}

Caddy sends X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-For automatically, which means x-forwarded-fetch works without extra configuration. For most new Fedify deployments where you don't already have an nginx footprint to fit into, Caddy is the path of least resistance.

TIP

Whichever proxy you use, make sure it forwards Accept and Content-Type verbatim and does not rewrite or strip them. ActivityPub content negotiation depends on these headers matching application/ld+json or application/activity+json exactly. A surprising number of default proxy rules on CDNs rewrite or drop them and silently break federation.

Container deployments

Running Fedify in a container does not change the application's architecture, but it changes how you supervise, scale, and secure it. This section covers the container-specific pieces that matter for a Fedify app: the shape of a minimal Dockerfile for each runtime, a Compose file that wires up the application, a worker, and the backing services it typically needs, and some notes on Kubernetes and managed container platforms.

Dockerfile

A minimal Node.js Dockerfile follows the familiar pattern: install dependencies, copy the source, expose the port, run the server. Keep the runtime image small (the -alpine or -slim variants are usually fine), run as a non-root user, and depend on process supervision from the orchestrator (Compose restart:, Kubernetes restartPolicy, etc.) rather than baking in a process manager.

Node.js
dockerfile
FROM node:24-alpine

# Install runtime OS packages your app needs (e.g., ffmpeg for media).
RUN apk add --no-cache pnpm

WORKDIR /app
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile --prod

COPY . .

# Run as an unprivileged user.  The stock `node` user UID 1000 exists in
# the official node images.
USER node

EXPOSE 3000
CMD ["pnpm", "run", "start"]

For Deno, use an official Deno image and lean on deno task for the command surface. Deno's permissions flags belong in the start task in deno.json, not in the Dockerfile, so that they are version-controlled with the code:

Deno
dockerfile
FROM denoland/deno:2

WORKDIR /app
COPY deno.json deno.lock ./
RUN deno install

COPY . .
RUN deno task build

USER deno
EXPOSE 8000
CMD ["deno", "task", "start"]

TIP

If you build multi-arch images (linux/amd64 and linux/arm64 are the common pair for fediverse servers, since many operators run on ARM VPSes for cost), use docker buildx with --platform=linux/amd64,linux/arm64. Avoid base images that only ship amd64.

Docker Compose/Podman Compose

A single compose.yaml is usually enough to describe a Fedify deployment: the app itself, optionally a separate worker process from the same image, and the KV/MQ backend (Postgres or Redis). Podman Compose understands the same file format, so the example below works unchanged under both Docker and Podman.

compose.yaml
yaml
services:
  web:
    image: ghcr.io/example/my-fedify-app:latest
    environment:
      NODE_TYPE: web
      DATABASE_URL: postgres://fedify:${DB_PASSWORD}@postgres:5432/fedify
      REDIS_URL: redis://redis:6379/0
      BEHIND_PROXY: "true"
      FEDIFY_ORIGIN: https://example.com
      SECRET_KEY: ${SECRET_KEY}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    ports:
      - "127.0.0.1:3000:3000"
    restart: unless-stopped

  worker:
    image: ghcr.io/example/my-fedify-app:latest
    environment:
      NODE_TYPE: worker
      DATABASE_URL: postgres://fedify:${DB_PASSWORD}@postgres:5432/fedify
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY: ${SECRET_KEY}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped

  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: fedify
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: fedify
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "fedify"]
      interval: 10s
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:

The web and worker services run the same image with different NODE_TYPE environment variables so that your application code can decide whether to bind an HTTP port or only start the message queue processor. See Separating web and worker nodes for the code pattern this mirrors. If you do not need that separation yet, drop the worker service and keep a single combined process.

Bind the application port to 127.0.0.1 rather than 0.0.0.0, and run your reverse proxy on the host; this keeps the Fedify process unreachable from the public internet, which is the invariant that the canonical origin guarantee depends on.

Kubernetes

For deployments large enough to justify Kubernetes, the same pattern applies—just spread across more objects. The essentials:

  • Two Deployments: one for web pods (multiple replicas, behind a Service and Ingress) and one for worker pods (replicas tuned to queue depth, no Service).
  • ConfigMap for non-sensitive environment variables and a Secret for instance-wide credentials. Per-actor key pairs belong in the application database, not in a Secret.
  • Ingress terminating TLS with cert-manager. Most Fedify apps don't need anything exotic here; a default nginx-ingress with proxy-body-size: 10m is a reasonable starting point.
  • HorizontalPodAutoscaler on the worker Deployment targeting queue depth (via a custom metric from your MQ backend) or CPU. Web pods usually scale on CPU or request count.
  • StatefulSet + PVC for PostgreSQL if you self-host it, or an external managed database; Fedify is indifferent as long as the connection string works.

This document does not attempt to replace the upstream Kubernetes documentation—the mechanics of Deployments, Services, and Ingress are the same as for any other HTTP service. The Fedify-specific pieces are the ones covered throughout this guide: origin pinning, forwarded headers, worker separation, persistent KV/MQ, and actor key persistence.

Managed container platforms

Platform-as-a-service container hosts are the fastest way to get a Fedify app into production if you don't want to operate the underlying infrastructure yourself. Rather than duplicate each vendor's documentation, this section lists which Fedify constraints to watch for. Follow the links for setup details.

Fly.io
Works well with Fedify. You can run web and worker processes as separate processes in one fly.toml and scale them independently. Enable HTTP/2 in [[services]] and make sure the forwarded-headers behavior matches what x-forwarded-fetch expects.
AWS ECS/AWS EKS
Standard container-orchestration on AWS. If you use ALB as the ingress, its request/response byte limits and header handling behave like nginx with generous defaults; the Reverse proxyAccept/Content-Type tip still applies.
Google Cloud Run
Runs a single container per service with no persistent disk and request-scoped execution. Worker separation using a long-running queue consumer does not fit Cloud Run's execution model well; if you need that separation, prefer a platform that supports long-running processes (Fly.io, Kubernetes) or move the queue backend to one with a native push consumer.
Render/Railway
Both treat Fedify apps as ordinary Node.js or Deno services and work well for small-to-medium deployments. Define a separate “background worker” service for the queue processor.

Separating web and worker nodes

By default, a Fedify process both accepts HTTP requests and runs the message-queue consumer that delivers outgoing activities and dispatches incoming ones. For low-traffic servers this works fine. For anything busy enough to care about tail latency—or for any server where a queue backlog during a federation spike would hurt web responsiveness—split these roles into separate processes:

  • Web nodes serve HTTP, enqueue outgoing activities, and accept incoming ones. They do not consume the queue.
  • Worker nodes consume the queue and process delivery. They do not serve HTTP and should not be exposed through your load balancer.

This is a Fedify-level concern implemented with two options: ~FederationOptions.manuallyStartQueue: true tells Fedify not to start the queue consumer automatically, and Federation.startQueue() starts it only on nodes that should consume.

typescript
import { 
createFederation
} from "@fedify/fedify";
import {
RedisMessageQueue
} from "@fedify/redis";
import
Redis
from "ioredis";
import
process
from "node:process";
const
federation
=
createFederation
<void>({
queue
: new
RedisMessageQueue
(() => new
Redis
()),
manuallyStartQueue
: true,
// Other options... }); if (
process
.
env
.
NODE_TYPE
=== "worker") {
const
controller
= new
AbortController
();
process
.
on
("SIGINT", () =>
controller
.
abort
());
process
.
on
("SIGTERM", () =>
controller
.
abort
());
await
federation
.
startQueue
(
undefined
, {
signal
:
controller
.
signal
});
}

The Separating message processing from the main process section has the complete reference, including the Deno variant and the table describing which role enqueues versus processes.

The deployment-side pieces are:

Compose
Two services referencing the same image with different NODE_TYPE environment variables, as in the Compose example above.
systemd
Either two separate units (fedify-web.service and fedify-worker.service, each with its own EnvironmentFile=), or a single templated fedify@.service unit instantiated twice (systemctl start fedify@web.service fedify@worker.service).
Kubernetes
Two Deployments. Only the web Deployment gets a Service and Ingress. Scale workers on queue depth (via a custom metric adapter reading from your MQ backend) rather than CPU—a queue that's falling behind is not necessarily CPU-bound.

WARNING

Do not place worker nodes behind a load balancer or expose them on a public address. They do not accept HTTP requests (or rather, they accept them but don't enqueue properly), and exposing them weakens the invariant that every Fedify HTTP response is signed by a node that has the full web configuration.

Avoid immediate: true in production

Context.sendActivity() accepts an immediate: true option that bypasses the message queue and attempts delivery synchronously as part of the current request. It has a specific purpose—delivery in environments where no queue is configured, or in tests—but it is actively dangerous in production:

  • Remote servers that are slow to respond will block your request.
  • There is no retry on failure; a single transient network error silently loses the activity.
  • It ties delivery success to request lifetime, which breaks the invariant that sendActivity() is fire-and-forget from the caller's point of view.

Before launch, search your codebase for immediate: true and remove every occurrence that isn't in a test fixture.

Parallel processing and connection pools

If you wrap your queue in ParallelMessageQueue(queue, N) to consume messages concurrently on a single worker process, make sure the database connection pool behind your KV store and MQ can accommodate at least N plus a few extra connections. A pool that's too small won't cause errors you'll notice immediately—it causes jobs to stall waiting on connections, which looks like a slow queue rather than a misconfiguration.

See Parallel message processing for the full context, which includes specific notes about PostgresMessageQueue and shared pools.

Serverless and edge deployments

Fedify runs on two classes of platform that don't fit the long-running process model: Cloudflare Workers and Deno Deploy. Both can host a Fedify application with zero self-managed infrastructure, at a cost that scales down to near-zero for low-traffic servers. The trade-off is that each platform imposes architectural constraints that shape how the code is organized—so unlike the traditional- and container-based sections above, the choice here affects your application code, not just the deployment configuration.

Cloudflare Workers

Cloudflare Workers support is available in Fedify 1.6.0 and later.

Cloudflare Workers is an edge runtime with per-request execution limits and no mutable global state between invocations. Platform services—KV, Queues, R2, D1—are exposed through the env parameter of the request handler rather than as ambient imports. Fedify accommodates this through the builder pattern and the @fedify/cfworkers package, which provides WorkersKvStore and WorkersMessageQueue.

Node.js compatibility

Fedify depends on Node.js APIs for cryptography and DNS, so Workers need the Node.js compatibility flag. In your wrangler.jsonc:

jsonc
"compatibility_date": "2025-05-31",
"compatibility_flags": ["nodejs_compat"],

See the Node.js compatibility documentation for details.

Builder pattern

Because env (the handle to KV, Queues, and other bindings) is only available inside the request handler, you cannot instantiate Federation at module load time. Use createFederationBuilder() to define your dispatchers and build the Federation object per request:

typescript
import { 
createFederationBuilder
} from "@fedify/fedify";
import {
WorkersKvStore
,
WorkersMessageQueue
} from "@fedify/cfworkers";
const
builder
=
createFederationBuilder
<
Env
>();
builder
.
setActorDispatcher
("/users/{identifier}", async (
ctx
,
identifier
) => {
// Actor logic... }); export default { async
fetch
(
request
:
Request
,
env
:
Env
):
Promise
<Response> {
const
federation
= await
builder
.
build
({
kv
: new
WorkersKvStore
(
env
.
KV_NAMESPACE
),
queue
: new
WorkersMessageQueue
(
env
.
QUEUE
),
}); return
federation
.
fetch
(
request
, {
contextData
:
env
});
}, };

Manual queue processing

Cloudflare Queues deliver messages by invoking your Worker's queue() export rather than via a polling API, so WorkersMessageQueue cannot implement listen() the traditional way. Wire the handler manually:

typescript
export default {
  // ... fetch handler above

  async 
queue
(
batch
:
MessageBatch
<unknown>,
env
:
Env
):
Promise
<void> {
const
federation
= await
builder
.
build
({
kv
: new
WorkersKvStore
(
env
.
KV_NAMESPACE
),
queue
: new
WorkersMessageQueue
(
env
.
QUEUE
),
}); for (const
message
of
batch
.
messages
) {
try { await
federation
.
processQueuedTask
(
env
,
message
.
body
as unknown as
Message
,
);
message
.
ack
();
} catch {
message
.
retry
();
} } }, };

If you use ordering keys, instantiate WorkersMessageQueue with an orderingKv namespace and call WorkersMessageQueue.processMessage() before Federation.processQueuedTask(). See the WorkersMessageQueue section for a complete example.

Native retry

Cloudflare Queues provide native retry with exponential backoff and dead-letter queue support, which Fedify recognizes through MessageQueue.nativeRetrial. When native retry is available, Fedify skips its own retry logic and relies on the backend. Configure max_retries and a dead_letter_queue in your Queue definition in wrangler.jsonc rather than in application code.

Secrets and WAF

Store secrets with wrangler secret put rather than committing them to wrangler.jsonc's vars section. The vars section is visible in the dashboard and to anyone with read access to the Worker; secrets are encrypted.

Cloudflare's default WAF Bot Protection and “Managed Challenge” rules sometimes treat fediverse user agents or the application/activity+json content type as suspicious and challenge them, which breaks federation silently (remote servers don't solve CAPTCHAs). If your Worker sits behind a Cloudflare WAF, add a skip rule for requests whose Accept or Content-Type contains application/activity+json or application/ld+json, and whitelist known-good fediverse user agents.

Example deployment

For a complete working example, see the Cloudflare Workers example in the Fedify repository, which demonstrates a minimal ActivityPub server deployed to Workers.

Deno Deploy

Deno Deploy is a serverless platform for Deno applications with global distribution and built-in persistence through Deno KV. At the time of writing, Deno Deploy offers two products:

  • Deno Deploy Early Access (EA) is the current generation and the one you should target for new deployments. It runs on Deno 2 with improved cold-start behavior, native HTTP/3, and first-class OpenTelemetry support.
  • Deno Deploy Classic is the previous generation. It is now deprecated and scheduled to shut down on July 20, 2026; existing applications must migrate to Deno Deploy EA before that date.

Fedify targets Deno Deploy (both EA and Classic) through the @fedify/denokv package, which exposes DenoKvStore and DenoKvMessageQueue. Deno Deploy EA's Deno KV is automatically available—no configuration required, no separate database to provision:

typescript
import { createFederation } from "@fedify/fedify";
import { DenoKvStore, DenoKvMessageQueue } from "@fedify/denokv";

const kv = await Deno.openKv();

const federation = createFederation<void>({
  kv: new DenoKvStore(kv),
  queue: new DenoKvMessageQueue(kv),
  // Other configuration...
});

Deno.serve((request) => federation.fetch(request, { contextData: undefined }));

DenoKvMessageQueue exposes native retry via MessageQueue.nativeRetrial, so Fedify delegates retry semantics to Deno KV's built-in exponential-backoff mechanism.

Security

Fedify servers face a different threat model than most web applications. Content arrives from strangers' servers, often as HTML, usually signed but not always usefully so. URLs point at resources you must then fetch from the public internet. Every user is potentially the target of an attacker on some other instance halfway around the world. Three concerns matter far more in this setting than the generic web-security checklist suggests: cross-site scripting through federated HTML, server-side request forgery through follow-on fetches, and the safekeeping of the cryptographic material that identifies your instance and its actors.

Cross-site scripting (XSS)

ActivityPub carries post content as HTML in fields like content, summary, and name. Remote servers can and do put arbitrary markup in these fields—including, if they are malicious or compromised, <script> tags, javascript: URLs, onerror handlers, CSS expressions, and everything else in the usual XSS playbook. Fedify does not sanitize this content for you, because what is safe depends on how and where you render it (rich timeline? plain text notification? microformats-annotated HTML?), so the obligation falls on your application.

Always pass federated HTML through an allowlist-based sanitizer before rendering. Never try to write your own regex-based sanitizer for this— HTML is not a regular language, and the failure modes of naive sanitizers are exactly the mutation XSS vectors attackers exploit in practice.

typescript
import sanitize from "sanitize-html";

const safeHtml = sanitize(post.content, {
  allowedTags: [
    "a", "br", "p", "span", "strong", "em", "del", "blockquote",
    "code", "pre", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
  ],
  allowedAttributes: {
    a: ["href", "rel", "class", "target"],
    span: ["class"],
  },
  allowedSchemes: ["http", "https", "mailto"],
  transformTags: {
    // Force rel="nofollow noopener ugc" on every link.
    a: sanitize.simpleTransform("a", {
      rel: "nofollow noopener ugc",
      target: "_blank",
    }),
  },
});
typescript
import { FilterXSS } from "xss";

const filter = new FilterXSS({
  whiteList: {
    a: ["href", "rel", "class"],
    span: ["class"],
    strong: [],
    em: [],
    del: [],
    p: [],
    br: [],
    blockquote: [],
    code: ["class"],
    pre: [],
    ul: [],
    ol: [],
    li: [],
    h1: [],
    h2: [],
    h3: [],
    h4: [],
    h5: [],
    h6: [],
  },
  stripIgnoreTagBody: ["script", "style"],
});

const safeHtml = filter.process(post.content);

A strong Content-Security-Policy header on your frontend provides defense in depth—a missed sanitization bug becomes a rendering bug rather than a compromise. At minimum, forbid inline scripts (script-src 'self') and inline event handlers.

CAUTION

Do not skip sanitization on “trusted” instances. The ActivityPub trust boundary is the HTTP signature on incoming activities, which only proves the activity came from a given actor; it says nothing about whether that actor's server is honest or whether the account has been hijacked. Sanitize every post, every summary, every actor bio, from every server, always.

Server-side request forgery (SSRF)

ActivityPub forces your server to fetch URLs supplied by untrusted parties: when resolving remote actors, loading linked objects, dereferencing collections, verifying signatures against the signer's public-key document. Without defenses, an attacker can supply URLs that resolve to your cloud metadata service, internal admin endpoints, or RFC 1918 addresses, and your server will obediently make those requests on their behalf and return (or expose in error messages) the responses.

Fedify's built-in document loaders defend against this by default:

  • lookupObject(), getDocumentLoader(), and ~Context.getAuthenticatedDocumentLoader() reject URLs that resolve to loopback, link-local, private (RFC 1918), or ULA/IPv6-link-local addresses, and they refuse the localhost hostname outright. At validation time, DNS is resolved and every returned IP is checked to be public, which helps mitigate DNS-rebinding-based SSRF.
  • The protection is implemented in validatePublicUrl(), which you can call directly from application code if you need the same checks for your own fetches.

There is an escape hatch—allowPrivateAddress: true on createFederation()—that disables the check. It exists to make tests runnable against localhost.

CAUTION

Never set allowPrivateAddress: true in a production configuration. It disables every SSRF defense Fedify provides. Gate it behind an environment variable that is set only in test environments, or omit it entirely from your production code path.

Fedify's protection does not extend to fetches you make yourself. Any time your application code calls fetch() (or any HTTP client) with a URL that originated from a remote server, you are on the hook for SSRF defense. The common cases where this happens in Fedify apps:

  • Downloading remote actors' avatar or header images to your own storage (to avoid hotlinking or to support media proxying).
  • Fetching attachment media (icon, image, document.url) for thumbnailing or content-type detection.
  • Resolving link previews or OEmbed endpoints discovered in post content.
  • Sending outbound webhooks to user-configured URLs.

For these, either wrap the fetch with a library like ssrfcheck or call validatePublicUrl() yourself before the fetch:

typescript
import { validatePublicUrl } from "@fedify/vocab-runtime";

async function fetchRemoteImage(url: URL): Promise<Response> {
  await validatePublicUrl(url.href);  // throws if the URL is unsafe
  return fetch(url, { redirect: "manual" });
}

Also disable following redirects by default, or constrain them: an attacker's URL might be public on its face but redirect to an internal address. fetch() in the platform runtimes follows redirects automatically; either set redirect: "manual" and validate each hop, or cap the total number of hops with a wrapper library.

Secret and key management

Fedify deployments have two distinct categories of cryptographic material to protect, and conflating them is a common cause of preventable incidents:

Instance-wide secrets
Session signing keys, OAuth client secrets, database passwords, S3 credentials, API tokens. These belong in environment variables or a secret manager, never in source control. For systemd, use EnvironmentFile=/etc/fedify/env with chmod 600. For Docker, use Docker secrets or supply them through the orchestrator's env facility without committing them to compose.yaml. For Kubernetes, use Secret resources (and consider External Secrets to sync from a backing store like Vault or AWS Secrets Manager). For Cloudflare Workers, use wrangler secret put—never vars in wrangler.jsonc.
Per-actor key pairs
Generated once per actor at registration time and stored in your database, one pair per row. Back them up together with the rest of your application data. Do not encrypt them with a secret you will forget to rotate to a new KMS when you migrate; losing these keys means losing the ability to sign outgoing activities for those actors, which for other fediverse servers looks indistinguishable from identity theft.

Two practical rules that are easy to forget:

  • Do not commit a .env file to the repository, even for development. Development secrets leak into production habits. Use .env.sample with dummy values and list .env in your .gitignore.
  • On deployment, the .env file (if you use one) should live on the server only long enough to be read into the process environment. The most common mistake is making it world-readable or leaving it in a web-accessible directory.

Other practices worth enforcing

Require HTTPS everywhere
ActivityPub assumes HTTPS. Most fediverse servers refuse to federate over HTTP, and those that accept it will reject the signatures on your activities. Redirect port 80 to 443 at the proxy and make sure your Federation.origin starts with https://.
Keep signature verification on
skipSignatureVerification exists only for controlled testing. A server with signature verification disabled will accept forged activities from any source, and there is no recovery from the resulting trust-cache pollution short of wiping state and refederating.
Block abusive instances early
Apply domain-level blocklists at the inbox listener so that incoming activities from known-abusive instances are rejected before you spend time parsing them. The fediverse maintains several community blocklists (oliphant/blocklists is one starting point); curate your own rather than importing them wholesale.
Keep the system clock in sync
HTTP signatures are valid only within signatureTimeWindow (one hour by default). Run NTP on every web and worker node. Clock drift is the second-most-common “it worked in staging” production issue after reverse-proxy misconfiguration.

Observability in production

Federation failures are often silent. An outbox that falls behind, a remote server that starts rejecting your signatures, a queue consumer that crashes—none of these necessarily produce an alert from basic HTTP monitoring, and the resulting trust-cache divergence between your server and its peers is hard to diagnose after the fact. The best defense is the observability you set up before launch.

The details of Fedify's logging and tracing APIs belong in their own chapters; this section covers only what's different about using them in production.

Structured logging

Fedify uses LogTape for logging. See the Logging chapter for the full API, including how to configure per-category log levels for Fedify, your application, and dependencies. For production specifically:

  • Emit structured logs (JSON Lines or similar) rather than free-form text. It takes the same amount of effort at write time and makes log aggregation, filtering, and alerting dramatically easier later.
  • Send logs to stderr only. stdout is conventionally reserved for application output that a shell pipeline might consume, and in practice mixing the two produces unreadable logs when the orchestrator captures both streams. systemd's StandardOutput=journal/ StandardError=journal and Docker's default both handle this correctly if you keep the streams separate.
  • Default the log level to info or warn. debug generates enough output from Fedify and its dependencies to fill a disk on a busy server; enable it per-category when investigating.
  • Redact sensitive fields before they reach disk. Actor private keys, session tokens, authorization headers, and user email addresses should never appear in logs. LogTape's filter API is the place to attach this transformation; do it once at the sink boundary rather than sprinkling it through call sites.

Distributed tracing

Fedify is instrumented for OpenTelemetry out of the box. See the OpenTelemetry chapter for the setup details, including the auto-instrumentation packages that capture ActivityPub operations across your infrastructure. For production:

  • Export traces via OTLP to whichever backend your organization uses (Jaeger, Tempo, Honeycomb, Datadog, Grafana Cloud—all speak OTLP). Configure the exporter through standard OTEL_EXPORTER_OTLP_ENDPOINT environment variables rather than hardcoding URLs.
  • Sample aggressively in production. A 100% sampling rate is fine in staging and prohibitively expensive under real traffic; 1–10% head-based sampling with tail-based error sampling is a common compromise.
  • On Deno 2.4 and later, set OTEL_DENO=1 to enable the built-in OpenTelemetry integration.
  • On Cloudflare Workers, use Workers Observability (enable observability.enabled: true in wrangler.jsonc) or a Workers-native tracing integration; OTLP export from inside a Worker is possible but not the default path.

Error reporting

For error aggregation, the pattern most Fedify applications use is a LogTape sink that forwards error-level records to Sentry via @logtape/sentry. This way you keep a single logging surface (LogTape) while picking up Sentry's grouping, release tracking, and issue assignment without a second instrumentation library.

What to actually monitor

The four metrics that correlate most directly with federation health:

Queue depth
How many outgoing activities are waiting in the queue. A steadily growing queue is the earliest sign that your worker nodes can't keep up with traffic. Depth that never drains to zero during low-traffic periods means you are permanently falling behind.
Inbox processing latency
The time between accepting an inbox request and finishing the side effects it triggers. Spikes typically correlate with either a queue backlog or a slow external dependency (database, remote signature fetch).
Outbox delivery success rate
The fraction of outgoing activities that receive a 2xx response from the target inbox. A drop from 95%+ to 80% probably means specific instances are blocking or rate-limiting you; a drop across the board means an outage at your end.
Remote 410/404 rate
The rate at which inbox deliveries hit 410 Gone or 404 Not Found responses. Some baseline is normal (actors get deleted), but sudden spikes often mean a large remote instance shut down or changed paths; permanentFailureStatusCodes means Fedify will stop retrying these, but you may want to prune orphan follower records yourself.

Any competent metrics backend will also want the usual process-level signals: CPU, RSS, event-loop lag, GC pauses, connection pool utilization for your KV/MQ backend. None of these are Fedify-specific, but all of them should be in place before you take real traffic.

ActivityPub-specific operational concerns

Generic deployment guides tell you how to keep a web service running. The items in this section are specific to ActivityPub and the fediverse, and they are the ones that surprise first-time operators because nothing else on the modern web behaves this way. None of them are optional for a server that expects to stay federated over the long term.

Domain name permanence

A Fedify server's domain name is effectively permanent. Once actors on your server have federated out—been followed, been mentioned, had their posts cached or boosted—remote servers store their URIs, which include your domain, in their local databases. Those stored URIs don't renegotiate when your domain changes. If you move to a new domain:

  • Every follow relationship for every actor breaks. Remote servers continue to POST to the old domain's inbox URL and either fail or deliver to a different server entirely.
  • Thread continuity breaks. Replies to your existing posts, if they reference your posts by URI, become orphaned.
  • Actor identity breaks. An actor at @alice@new.example is, federation-wise, a different actor from @alice@old.example; followers do not carry over.

ActivityPub defines a Move activity that partially mitigates this by asking followers to migrate, but:

  • Not every fediverse server implements Move.
  • Move transfers the follower graph but not the actor's history, posts, interactions, or mutual relationships.
  • Remote servers that have cached your old actor under a blocklist will not automatically apply the block to the new one.

Pick your final domain before you federate. If you are not sure your current domain will survive, deploy first to a subdomain you control (ap.example.com rather than example.com) so that moving the marketing site later doesn't require migrating the fediverse presence.

If you use the WebFinger/web-origin split (handleHost vs webOrigin), the webOrigin is the permanent one— that's the domain that appears in actor URIs. handleHost can be moved later with a WebFinger redirect, though this is rarely worth the complexity.

Graceful shutdown and service retirement

Turning off a Fedify server is not the same as turning off a web application. A cold shutdown leaves every follower, on every remote instance, still trying to deliver activities to your inbox—with your signatures still cached as valid, with your actor URIs still in their follower lists. Even well-behaved instances will keep retrying for days before giving up, and the traffic will follow your DNS until your certificate expires.

The right procedure is:

  1. Freeze writes first. Stop accepting new registrations, new posts, and new follow requests. Your users should see an announcement explaining the shutdown and, if relevant, pointers to migration tools (Move, account exports).

  2. Broadcast Delete activities for every local actor to its followers and to the inboxes of servers that have interacted with that actor. Use Context.sendActivity() and pass orderingKey so that the Delete can't overtake earlier Creates/Updates for the same actor still in the outbound queue. Remote servers that process the Delete will remove the actor from their local cache and stop delivering.

  3. Replace actor dispatchers with Tombstone responses. Change your actor dispatcher to return a Tombstone instead of the live actor object. Fedify will respond to actor fetches with HTTP 410 Gone and a Tombstone body. Set formerType on the Tombstone to the original ActivityStreams type (Person, Service, etc.) so that remote servers can preserve the type information in their own logs. See Actor dispatcher for the Tombstone-returning pattern.

    typescript
    federation
    .
    setActorDispatcher
    (
    "/users/{identifier}", (
    ctx
    ,
    identifier
    ) =>
    new
    Tombstone
    ({
    id
    :
    ctx
    .
    getActorUri
    (
    identifier
    ),
    formerType
    :
    Person
    ,
    deleted
    :
    deletedAt
    ,
    }), );
  4. Keep the 410 Gone response online for weeks or months, not hours. Remote servers' caches expire on different schedules, and some will keep retrying for a long time. Fedify's default permanentFailureStatusCodes includes 410, so well-behaved remote servers will stop trying once they receive it— but only if they actually reach your server to receive it. Serving 410 from a cheap static host for a year after shutdown is inexpensive and dramatically reduces the long-term federation noise your domain generates.

  5. Only then take down DNS, release the domain, or retire the infrastructure.

Skipping any of these steps is not a catastrophe—the fediverse is resilient to servers disappearing ungracefully—but it is discourteous, and the ghost traffic it generates is a problem for everyone else's operators more than for yours.

Handling inbound failures

Federation happens with no central coordination, and your peers will sometimes misbehave. A few patterns are worth configuring before you hit them in production:

Permanent failures vs. transient failures
permanentFailureStatusCodes controls which HTTP status codes from remote inboxes mean “don't retry.” The defaults (404, 410) cover actor-gone cases; you may want to add others (for example, codes specific to instances that return 403 for blocks) if you observe a lot of useless retries against instances that have started refusing you.
Signature time windows and clock drift
HTTP signatures are rejected if the signing time is outside signatureTimeWindow (one hour by default). On a fleet with unsynchronized clocks, this produces verification failures that are hard to reproduce because they correlate with which node handled the request. Run NTP on every node (including workers) and alert on clock drift.
DNS rebinding during application fetches
Fedify's built-in document loaders resolve DNS at validation time and check every returned IP, which helps mitigate DNS-rebinding-based SSRF on actor lookups and document fetching. Application code that calls fetch() directly on a URL derived from a remote actor (for example, to download an avatar) does not get this protection; see the SSRF section for the validatePublicUrl() pattern.
Abusive remotes
Rate-limit inbox requests per remote server, not just per-IP. A single poorly-behaved instance can flood your inbox with retries during its own outage, and per-IP limits won't distinguish that from a flood of independent users.

Pre-launch checklist

A condensed pass through everything above. If you can tick all of these before taking real traffic, you have covered the pitfalls this guide was written to warn you about.

Configuration

  • origin is pinned, or x-forwarded-fetch is wired up behind a trusted reverse proxy and gated on BEHIND_PROXY.
  • MemoryKvStore and InProcessMessageQueue are not used outside of tests; a persistent KV and MQ backend is configured.
  • Actor key pairs are generated once per actor at registration time and stored in the database—not regenerated on each restart.
  • allowPrivateAddress is unset (or explicitly false) in production.
  • skipSignatureVerification is unset (or explicitly false) in production.

Scaling

  • Web and worker roles are split with manuallyStartQueue: true and Federation.startQueue(); workers are not reachable through the load balancer.
  • No sendActivity() call sites use immediate: true outside of tests.
  • ParallelMessageQueue parallelism, if used, is matched by a database connection pool sized for N + headroom.

Runtime and infrastructure

  • The process runs under a supervisor (systemd, Compose, Kubernetes, or platform-equivalent) that restarts on crash and respects SIGTERM for graceful shutdown.
  • A reverse proxy terminates TLS, forwards Host, X-Forwarded-Host, and X-Forwarded-Proto, and preserves the Accept and Content-Type headers verbatim.
  • Port 80 redirects to 443; no part of the federation surface is reachable over plain HTTP.
  • NTP is running on every node; clock drift is monitored.

Security

  • All federated HTML (post content, summaries, actor bios, display names) is sanitized through an allowlist-based library before rendering to browsers.
  • A Content-Security-Policy header is set and forbids inline scripts and inline event handlers.
  • Application-code fetches of remote-supplied URLs (avatars, attachments, link previews, webhooks) are guarded with validatePublicUrl() or an equivalent SSRF defense, and redirects are either disabled or validated per hop.
  • Secrets are managed via the environment or a secret manager, not committed to source; actor private keys live in the database, backed up alongside the rest of the application data.

Observability

  • Structured logs are emitted to stderr at info or warn level by default; sensitive fields are redacted at the sink.
  • OpenTelemetry exports traces to your backend of choice with a realistic sampling rate.
  • Error aggregation (Sentry or equivalent) is wired up through a LogTape sink.
  • Queue depth, inbox processing latency, outbox delivery success rate, and remote 410/404 rate are all tracked and alert on sustained anomalies.

ActivityPub

  • The domain is the one you intend to keep forever, and the WebFinger/web-origin split (if any) has the permanent host as webOrigin.
  • A service-retirement runbook exists (freeze writes, broadcast Delete, serve Tombstone and 410 Gone for an extended period) before it is ever needed.