Set up self-hosted Inngest on macOS as a durable background task manager for AI agents. Interactive Q&A to match intent — from Docker one-liner to full k8s deployment with persistent state. Use when: 'set up inngest', 'background tasks', 'durable workflows', 'self-host inngest', 'event-driven functions', 'cron jobs', or any request for a local workflow engine.
Set up self-hosted Inngest on macOS as a durable background task manager for AI agents. Interactive Q&A to match intent — from Docker one-liner to full k8s deployment with persistent state. Use when: 'set up inngest', 'background tasks', 'durable workflows', 'self-host inngest', 'event-driven functions', 'cron jobs', or any request for a local workflow engine.
This skill sets up Inngest as a self-hosted durable workflow engine on a Mac. Inngest gives you event-driven functions where each step retries independently — if step 3 of 5 fails, only step 3 retries.
Before You Start
Required:
macOS with Docker (Docker Desktop, OrbStack, or Colima)
Bun or Node.js for the worker process
Optional:
k8s cluster (k3d, Talos, etc.) for persistent deployment
Redis (for state sharing between functions and gateway integration)
Intent Alignment
Ask the user these questions to determine scope.
Question 1: What are you building?
Quick experiment — I want to try Inngest, run a function, see the dashboard
Persistent setup — I want this running all the time, surviving reboots, with real workflows
Full infrastructure — I want k8s-deployed Inngest with persistent storage, integrated with an agent gateway
Question 2: What runtime for the worker?
Bun — fast, good TypeScript support, what joelclaw uses
Node.js — standard, widest compatibility
Existing framework — I have a Next.js/Express/Hono app already
Question 3: What kind of work?
AI agent tasks — coding loops, content processing, transcription pipelines
General background jobs — scheduled tasks, webhooks, data processing
Both — mixed workloads
Setup Tiers
Signing Keys (required)
As of Feb 2026, inngest/inngest:latest requires signing keys. Without them the container crash-loops with Error: signing-key is required.
Now Inngest state survives container restarts. --restart unless-stopped brings it back after Docker restarts.
Tier 3: Kubernetes (production-grade)
For full persistence with proper health checks. Requires a k8s cluster (k3d, Talos, etc.).
# inngest.yamlapiVersion:apps/v1kind:StatefulSetmetadata:name:inngestnamespace:defaultspec:serviceName:inngest-svc# NOT "inngest" — avoids env var collisionreplicas:1selector:matchLabels:app:inngesttemplate:metadata:labels:app:inngestspec:containers:-name:inngestimage:inngest/inngest:latestcommand: ["inngest", "start", "--host", "0.0.0.0"]
ports:-containerPort:8288volumeMounts:-name:datamountPath:/var/lib/inngestvolumeClaimTemplates:-metadata:name:dataspec:accessModes: ["ReadWriteOnce"]
resources:requests:storage:5Gi---apiVersion:v1kind:Servicemetadata:name:inngest-svc# CRITICAL: not "inngest" — k8s creates INNGEST_PORT env var that conflictsnamespace:defaultspec:type:NodePortselector:app:inngestports:-port:8288targetPort:8288nodePort:8288
Apply:
kubectl apply -f inngest.yaml
⚠️ GOTCHA: Never name a k8s Service the same as the binary it runs. A Service named inngest creates INNGEST_PORT=tcp://10.43.x.x:8288. The Inngest binary expects INNGEST_PORT to be an integer. Name it inngest-svc.
Build a Worker
Step 1: Initialize
mkdir my-worker && cd my-worker
bun init -y
bun add inngest @inngest/ai hono
Step 2: Create the Inngest client
// src/inngest.tsimport { Inngest } from"inngest";
// Type your events for full type safetytypeEvents = {
"task/process": { data: { url: string; outputPath: string } };
"task/completed": { data: { url: string; result: string } };
};
exportconst inngest = newInngest({
id: "my-worker",
schemas: newEventSchemas().fromRecord<Events>(),
});
Step 3: Write your first function
// src/functions/process-task.tsimport { inngest } from"../inngest";
exportconst processTask = inngest.createFunction(
{
id: "process-task",
concurrency: { limit: 1 }, // one at a timeretries: 3,
},
{ event: "task/process" },
async ({ event, step }) => {
// Step 1: Download — retries independently on failureconst localPath = await step.run("download", async () => {
const response = awaitfetch(event.data.url);
const buffer = await response.arrayBuffer();
const path = `/tmp/downloads/${crypto.randomUUID()}.bin`;
awaitBun.write(path, buffer);
return path; // Only the path is stored in step state (claim-check pattern)
});
// Step 2: Process — if this fails, download doesn't re-runconst result = await step.run("process", async () => {
const data = awaitBun.file(localPath).text();
// ... your processing logicreturn { processed: true, size: data.length };
});
// Step 3: Emit completion event — chains to other functionsawait step.sendEvent("notify-complete", {
name: "task/completed",
data: { url: event.data.url, result: JSON.stringify(result) },
});
return { status: "done", result };
}
);
Docker starts → Inngest server comes up with persisted state (SQLite)
launchd starts → worker process registers functions
Any incomplete function runs resume from their last completed step
Gotchas
@inngest/ai is a required peer dep.bun add inngest alone isn't enough — the SDK imports @inngest/ai at startup. Worker crashes with Cannot find module '@inngest/ai'. Always install both.
Docker-to-host networking. If Inngest runs in Docker and the worker on the host, the server can't reach localhost:3111. Pass --sdk-url http://host.docker.internal:3111/api/inngest on the docker run command. This is Docker Desktop/OrbStack-specific; Linux Docker needs --add-host=host.docker.internal:host-gateway.
Service naming in k8s: Never name a Service the same as the binary. INNGEST_PORT env var collision crashes the container.
Step output size: Keep step return values small. Use claim-check pattern for large data.
Worker re-registration: After Inngest server restart, the worker needs to re-register. Restart the worker or hit the registration endpoint.
Trigger drift: Functions register their triggers at startup. If you change a trigger in code but the server has stale state, the old trigger stays active. Build an auditor or restart both server and worker.
INNGEST_DEV=1: Required for local development. Without it, the worker tries to register with Inngest Cloud.
Concurrency = 1 for GPU work: Transcription, inference — anything that saturates a GPU needs concurrency: { limit: 1 }.