
Inngest Local
- 39 installs
- 61 repo stars
- Updated August 4, 2026
- joelhooks/joelclaw
Set up self-hosted Inngest on macOS as a durable background task manager, from a Docker one-liner to a full k8s deployment with persistent state.
About
Stands up self-hosted Inngest as a local durable workflow engine, scoped by interactive Q&A. A developer uses it to run event-driven functions, background jobs, or cron work with independent step retries.
- Scopes from quick experiment to k8s-deployed persistent infrastructure
- Supports Bun or Node workers and AI-agent or general background workloads
Inngest Local by the numbers
- 39 all-time installs (skills.sh)
- Ranked #822 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joelhooks/joelclaw --skill inngest-localAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 39 |
|---|---|
| repo stars | ★ 61 |
| Last updated | August 4, 2026 |
| Repository | joelhooks/joelclaw ↗ |
What it does
Set up self-hosted Inngest on macOS as a durable background task manager, from a Docker one-liner to a full k8s deployment with persistent state.
Files
Self-Hosted Inngest on macOS
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 (Talos on Colima, 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?
1. Quick experiment — I want to try Inngest, run a function, see the dashboard 2. Persistent setup — I want this running all the time, surviving reboots, with real workflows 3. Full infrastructure — I want k8s-deployed Inngest with persistent storage, integrated with an agent gateway
Question 2: What runtime for the worker?
1. Bun — fast, good TypeScript support, what joelclaw uses 2. Node.js — standard, widest compatibility 3. Existing framework — I have a Next.js/Express/Hono app already
Question 3: What kind of work?
1. AI agent tasks — coding loops, content processing, transcription pipelines 2. General background jobs — scheduled tasks, webhooks, data processing 3. 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.
# Generate once, reuse across tiers
INNGEST_SIGNING_KEY="signkey-dev-$(openssl rand -hex 16)"
INNGEST_EVENT_KEY="evtkey-dev-$(openssl rand -hex 16)"
echo "INNGEST_SIGNING_KEY=$INNGEST_SIGNING_KEY" >> .env.inngest
echo "INNGEST_EVENT_KEY=$INNGEST_EVENT_KEY" >> .env.inngestTier 1: Docker One-Liner (experiment)
Get Inngest running in 30 seconds:
docker run -d --name inngest \
-p 8288:8288 \
-e INNGEST_SIGNING_KEY="$INNGEST_SIGNING_KEY" \
-e INNGEST_EVENT_KEY="$INNGEST_EVENT_KEY" \
inngest/inngest:latest \
inngest start --host 0.0.0.0Open http://localhost:8288 — you should see the Inngest dashboard.
Limitation: No persistent state. Container restart = lost history. Fine for experimenting.
Tier 2: Persistent Docker (daily driver)
Add a volume for SQLite state:
docker run -d --name inngest \
-p 8288:8288 \
-v inngest-data:/var/lib/inngest \
-e INNGEST_SIGNING_KEY="$INNGEST_SIGNING_KEY" \
-e INNGEST_EVENT_KEY="$INNGEST_EVENT_KEY" \
--restart unless-stopped \
inngest/inngest:latest \
inngest start --host 0.0.0.0Now 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 (Talos on Colima, etc.).
# inngest.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: inngest
namespace: default
spec:
serviceName: inngest-svc # NOT "inngest" — avoids env var collision
replicas: 1
selector:
matchLabels:
app: inngest
template:
metadata:
labels:
app: inngest
spec:
containers:
- name: inngest
image: inngest/inngest:latest
command: ["inngest", "start", "--host", "0.0.0.0"]
ports:
- containerPort: 8288
volumeMounts:
- name: data
mountPath: /var/lib/inngest
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: Service
metadata:
name: inngest-svc # CRITICAL: not "inngest" — k8s creates INNGEST_PORT env var that conflicts
namespace: default
spec:
type: NodePort
selector:
app: inngest
ports:
- port: 8288
targetPort: 8288
nodePort: 8288Apply:
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 honoStep 2: Create the Inngest client
// src/inngest.ts
import { Inngest } from "inngest";
// Type your events for full type safety
type Events = {
"task/process": { data: { url: string; outputPath: string } };
"task/completed": { data: { url: string; result: string } };
};
export const inngest = new Inngest({
id: "my-worker",
schemas: new EventSchemas().fromRecord<Events>(),
});Step 3: Write your first function
// src/functions/process-task.ts
import { inngest } from "../inngest";
export const processTask = inngest.createFunction(
{
id: "process-task",
concurrency: { limit: 1 }, // one at a time
retries: 3,
},
{ event: "task/process" },
async ({ event, step }) => {
// Step 1: Download — retries independently on failure
const localPath = await step.run("download", async () => {
const response = await fetch(event.data.url);
const buffer = await response.arrayBuffer();
const path = `/tmp/downloads/${crypto.randomUUID()}.bin`;
await Bun.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-run
const result = await step.run("process", async () => {
const data = await Bun.file(localPath).text();
// ... your processing logic
return { processed: true, size: data.length };
});
// Step 3: Emit completion event — chains to other functions
await step.sendEvent("notify-complete", {
name: "task/completed",
data: { url: event.data.url, result: JSON.stringify(result) },
});
return { status: "done", result };
}
);Step 4: Serve it
// src/serve.ts
import { Hono } from "hono";
import { serve as inngestServe } from "inngest/hono";
import { inngest } from "./inngest";
import { processTask } from "./functions/process-task";
const app = new Hono();
// Health check
app.get("/", (c) => c.json({ status: "running", functions: 1 }));
// Inngest endpoint — registers functions with the server
app.on(
["GET", "POST", "PUT"],
"/api/inngest",
inngestServe({ client: inngest, functions: [processTask] })
);
export default {
port: 3111,
fetch: app.fetch,
};Step 5: Run it
INNGEST_DEV=1 bun run src/serve.tsThe worker starts, registers with Inngest at localhost:8288, and your function appears in the dashboard.
Step 6: Test it
Send an event via the dashboard (Events → Send Event) or curl:
curl -X POST http://localhost:8288/e/key \
-H "Content-Type: application/json" \
-d '{"name": "task/process", "data": {"url": "https://example.com/file.txt", "outputPath": "/tmp/out"}}'Watch it execute step-by-step in the dashboard.
Patterns
Event Chaining
Function A emits an event that triggers Function B:
// In function A:
await step.sendEvent("chain", { name: "pipeline/step-two", data: { result } });
// Function B triggers on that event:
export const stepTwo = inngest.createFunction(
{ id: "step-two" },
{ event: "pipeline/step-two" },
async ({ event, step }) => { /* ... */ }
);Concurrency Keys
Run one instance per project, but allow parallel across projects:
concurrency: {
key: "event.data.project",
limit: 1,
}Cron Functions
export const heartbeat = inngest.createFunction(
{ id: "heartbeat" },
[{ cron: "*/15 * * * *" }],
async ({ step }) => {
await step.run("check-health", async () => {
// ... system health checks
});
}
);Claim-Check Pattern
Large data between steps: write to file, pass path.
// ❌ DON'T: return large data from a step
const transcript = await step.run("transcribe", async () => {
return { text: hugeString }; // Step output has size limits!
});
// ✅ DO: write to file, return path
const transcriptPath = await step.run("transcribe", async () => {
const result = await transcribe(audioPath);
await Bun.write("/tmp/transcript.json", JSON.stringify(result));
return "/tmp/transcript.json";
});Make It Survive Reboots
Worker via launchd
<!-- ~/Library/LaunchAgents/com.you.inngest-worker.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.you.inngest-worker</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.bun/bin/bun</string>
<string>run</string>
<string>/path/to/your/worker/src/serve.ts</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>INNGEST_DEV</key><string>1</string>
<key>HOME</key><string>/Users/you</string>
<key>PATH</key><string>/usr/local/bin:/usr/bin:/bin:/Users/you/.bun/bin</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/tmp/inngest-worker.log</string>
<key>StandardErrorPath</key><string>/tmp/inngest-worker.log</string>
<key>WorkingDirectory</key><string>/path/to/your/worker</string>
</dict>
</plist>Load:
launchctl load ~/Library/LaunchAgents/com.you.inngest-worker.plistWhat happens on reboot
1. Docker starts → Inngest server comes up with persisted state (SQLite) 2. launchd starts → worker process registers functions 3. Any incomplete function runs resume from their last completed step
Gotchas
1. `@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.
2. 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.
3. Service naming in k8s: Never name a Service the same as the binary. INNGEST_PORT env var collision crashes the container.
4. Step output size: Keep step return values small. Use claim-check pattern for large data.
5. Worker re-registration: After Inngest server restart, the worker needs to re-register. Restart the worker or hit the registration endpoint.
6. 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.
7. `INNGEST_DEV=1`: Required for local development. Without it, the worker tries to register with Inngest Cloud.
8. Concurrency = 1 for GPU work: Transcription, inference — anything that saturates a GPU needs concurrency: { limit: 1 }.
Verification
- [ ] Inngest dashboard accessible at http://localhost:8288
- [ ] Worker shows as registered in dashboard (Functions tab)
- [ ] Send a test event — function executes in dashboard
- [ ] Kill the worker mid-function — restart worker, function resumes from last step
- [ ] (Tier 2+) Restart Docker — Inngest state is preserved
- [ ] (launchd) Reboot Mac — worker and Inngest both come back automatically
Setup Script (curl-first)
For automated setup, the user can run:
curl -sL joelclaw.com/scripts/inngest-setup.sh | bashOr with a specific tier:
curl -sL joelclaw.com/scripts/inngest-setup.sh | bash -s -- 2The script is idempotent, detects existing state, and scaffolds a worker with typed events.
Decision Chain (compressed ADRs)
This skill's architecture is backed by a chain of Architecture Decision Records. Unfurl as needed for tradeoff context.
ADR-0010 → ADR-0029 → current state
| Decision | Choice | Key Tradeoff | Link |
|---|---|---|---|
| Workflow engine | Inngest (self-hosted) | Step-level durability vs complexity. Cron+scripts has no per-step retry. | ADR-0010 |
| Container runtime | Colima (VZ framework) | Replaces Docker Desktop. Free, headless, less RAM. | ADR-0029 |
| k8s for 3 containers | Yes (Talos on Colima) | 380MB overhead for reconciliation loop + multi-node future. Docker Compose = no self-healing. | joel-deploys-k8s |
| Service naming | inngest-svc not inngest | k8s injects INNGEST_PORT env var. Binary expects integer, gets URL. | Hard-won debugging |
| Worker runtime | Bun + Hono | Faster cold start than Node. Hono = minimal HTTP. launchd KeepAlive for persistence. | Practical choice |
| Step data pattern | Claim-check (file path) | Step outputs have size limits. Write large data to disk, pass path between steps. | Inngest docs |
| Trigger auditing | Heartbeat cron auditor | Silent trigger drift broke promote function for days. Now audited every 15 min. | ADR-0037 |
Credits
- Inngest — the workflow engine
- joelclaw.com/inngest-is-the-nervous-system — architecture narrative
- joelclaw.com/self-hosting-inngest-background-tasks — human summary
interface:
icon_small: "./assets/small-logo.svg"
icon_large: "./assets/large-logo.png"
<svg width="16" height="16" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="JoelClaw icon">
<defs>
<clipPath id="circle-clip">
<circle cx="256" cy="256" r="248" />
</clipPath>
</defs>
<circle cx="256" cy="256" r="248" fill="#0a0a0a" />
<g clip-path="url(#circle-clip)">
<path fill="#ff1493" d="M175.656 22.375l-48.47 82.094c-23.017 4.384-43.547 11.782-60.124 22.374-24.436 15.613-40.572 37.414-45.5 67.875-4.79 29.62 1.568 68.087 24.125 116.093 93.162 22.88 184.08-10.908 257.25-18.813 37.138-4.012 71.196-.898 96.344 22.97 22.33 21.19 36.21 56.808 41.908 113.436 29.246-35.682 44.538-69.065 49.343-99.594 5.543-35.207-2.526-66.97-20.31-95.593-8.52-13.708-19.368-26.618-32-38.626l14.217-33-41.218 10.625c-8.637-6.278-17.765-12.217-27.314-17.782l-7.03-59.782-38.157 37.406c-12.418-5.186-25.184-9.804-38.158-13.812l-8.375-71.28-57.625 56.5c-9.344-1.316-18.625-2.333-27.812-2.97l-31.094-78.125zM222 325.345c-39.146 7.525-82.183 14.312-127.156 11.686 47.403 113.454 207.056 224.082 260.125 87-101.18 33.84-95.303-49.595-132.97-98.686z" />
</g>
</svg>