inngest-local

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Self-Hosted Inngest on macOS

在macOS上自托管Inngest

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.
本指南介绍如何在Mac上部署自托管的持久化工作流引擎Inngest。Inngest提供事件驱动函数能力,每个步骤可独立重试——如果5个步骤中的第3步失败,仅重试第3步即可。

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)
必需条件:
  • 安装了Docker的macOS设备(Docker Desktop、OrbStack或Colima均可)
  • 运行工作进程所需的Bun或Node.js环境
可选条件:
  • 用于持久化部署的k8s集群(k3d、Talos等)
  • Redis(用于函数间状态共享和网关集成)

Intent Alignment

需求对齐

Ask the user these questions to determine scope.
通过询问用户以下问题明确部署范围:

Question 1: What are you building?

问题1:你的使用场景是什么?

  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
  1. 快速实验 —— 我想试用Inngest,运行简单函数、查看仪表盘
  2. 持久化部署 —— 我需要服务长期运行、重启不丢失数据,用于实际工作流
  3. 完整基础设施 —— 我需要k8s部署的带持久化存储的Inngest,和Agent网关集成

Question 2: What runtime for the worker?

问题2:工作进程使用什么运行时?

  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
  1. Bun —— 速度快、TypeScript支持好,是joelclaw的常用选择
  2. Node.js —— 标准运行时,兼容性最广
  3. 现有框架 —— 我已经有Next.js/Express/Hono应用

Question 3: What kind of work?

问题3:主要用于什么类型的任务?

  1. AI agent tasks — coding loops, content processing, transcription pipelines
  2. General background jobs — scheduled tasks, webhooks, data processing
  3. Both — mixed workloads
  1. AI Agent任务 —— 编码循环、内容处理、转录流水线
  2. 通用后台任务 —— 定时任务、webhook、数据处理
  3. 两者都有 —— 混合工作负载

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
.
bash
undefined
从2026年2月起,
inngest/inngest:latest
镜像要求配置签名密钥,否则容器会 crash-loop 并报错
Error: signing-key is required
bash
undefined

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.inngest
undefined
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.inngest
undefined

Tier 1: Docker One-Liner (experiment)

层级1:Docker单行命令部署(实验用途)

Get Inngest running in 30 seconds:
bash
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.0
Open http://localhost:8288 — you should see the Inngest dashboard.
Limitation: No persistent state. Container restart = lost history. Fine for experimenting.
30秒内启动Inngest:
bash
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.0
打开 http://localhost:8288 即可看到Inngest仪表盘。
限制: 无持久化状态,容器重启会丢失历史数据,适合实验使用。

Tier 2: Persistent Docker (daily driver)

层级2:持久化Docker部署(日常使用)

Add a volume for SQLite state:
bash
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.0
Now Inngest state survives container restarts.
--restart unless-stopped
brings it back after Docker restarts.
新增SQLite状态存储卷:
bash
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.0
现在Inngest状态在容器重启后仍然保留,
--restart unless-stopped
配置会在Docker重启后自动恢复服务。

Tier 3: Kubernetes (production-grade)

层级3:Kubernetes部署(生产级)

For full persistence with proper health checks. Requires a k8s cluster (k3d, Talos, etc.).
yaml
undefined
适用于带健康检查的全持久化场景,需要提前准备k8s集群(k3d、Talos等)。
yaml
undefined

inngest.yaml

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: 8288

Apply:
```bash
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
.
apiVersion: apps/v1 kind: StatefulSet metadata: name: inngest namespace: default spec: serviceName: inngest-svc # 不能命名为"inngest",避免环境变量冲突 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 # 重要:不能命名为"inngest",k8s会生成冲突的INNGEST_PORT环境变量 namespace: default spec: type: NodePort selector: app: inngest ports:
  • port: 8288 targetPort: 8288 nodePort: 8288

应用配置:
```bash
kubectl apply -f inngest.yaml
⚠️ 注意事项: 永远不要将k8s Service命名为和运行的二进制文件同名。名为
inngest
的Service会生成
INNGEST_PORT=tcp://10.43.x.x:8288
的环境变量,但Inngest二进制期望
INNGEST_PORT
是整数,因此要命名为
inngest-svc

Build a Worker

构建工作进程

Step 1: Initialize

步骤1:初始化

bash
mkdir my-worker && cd my-worker
bun init -y
bun add inngest @inngest/ai hono
bash
mkdir my-worker && cd my-worker
bun init -y
bun add inngest @inngest/ai hono

Step 2: Create the Inngest client

步骤2:创建Inngest客户端

typescript
// 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>(),
});
typescript
// src/inngest.ts
import { Inngest } from "inngest";

// 为事件定义类型实现完整类型安全
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

步骤3:编写第一个函数

typescript
// 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 };
  }
);
typescript
// src/functions/process-task.ts
import { inngest } from "../inngest";

export const processTask = inngest.createFunction(
  {
    id: "process-task",
    concurrency: { limit: 1 },  // 一次仅运行一个实例
    retries: 3,
  },
  { event: "task/process" },
  async ({ event, step }) => {
    // 步骤1:下载 —— 失败时独立重试
    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;  // 仅路径存储在步骤状态中(凭证模式)
    });

    // 步骤2:处理 —— 如果该步骤失败,下载步骤不会重新运行
    const result = await step.run("process", async () => {
      const data = await Bun.file(localPath).text();
      // ... 你的处理逻辑
      return { processed: true, size: data.length };
    });

    // 步骤3:发送完成事件 —— 触发其他关联函数
    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

步骤4:启动服务

typescript
// 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,
};
typescript
// 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();

// 健康检查
app.get("/", (c) => c.json({ status: "running", functions: 1 }));

// Inngest端点 —— 向服务端注册函数
app.on(
  ["GET", "POST", "PUT"],
  "/api/inngest",
  inngestServe({ client: inngest, functions: [processTask] })
);

export default {
  port: 3111,
  fetch: app.fetch,
};

Step 5: Run it

步骤5:运行服务

bash
INNGEST_DEV=1 bun run src/serve.ts
The worker starts, registers with Inngest at localhost:8288, and your function appears in the dashboard.
bash
INNGEST_DEV=1 bun run src/serve.ts
工作进程启动后会自动向localhost:8288的Inngest注册,你的函数会出现在仪表盘上。

Step 6: Test it

步骤6:测试功能

Send an event via the dashboard (Events → Send Event) or curl:
bash
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.
通过仪表盘(事件 → 发送事件)或curl发送事件:
bash
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"}}'
在仪表盘中可以查看步骤级的执行过程。

Patterns

常用模式

Event Chaining

事件链式触发

Function A emits an event that triggers Function B:
typescript
// 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 }) => { /* ... */ }
);
函数A发送事件触发函数B:
typescript
// 在函数A中:
await step.sendEvent("chain", { name: "pipeline/step-two", data: { result } });

// 函数B基于该事件触发:
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:
typescript
concurrency: {
  key: "event.data.project",
  limit: 1,
}
每个项目仅运行一个实例,但不同项目可并行执行:
typescript
concurrency: {
  key: "event.data.project",
  limit: 1,
}

Cron Functions

定时函数

typescript
export const heartbeat = inngest.createFunction(
  { id: "heartbeat" },
  [{ cron: "*/15 * * * *" }],
  async ({ step }) => {
    await step.run("check-health", async () => {
      // ... system health checks
    });
  }
);
typescript
export const heartbeat = inngest.createFunction(
  { id: "heartbeat" },
  [{ cron: "*/15 * * * *" }],
  async ({ step }) => {
    await step.run("check-health", async () => {
      // ... 系统健康检查逻辑
    });
  }
);

Claim-Check Pattern

凭证模式(处理大数据)

Large data between steps: write to file, pass path.
typescript
// ❌ 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";
});
步骤间传递大数据时写入文件,仅传递路径:
typescript
// ❌ 不要这样做:从步骤返回大量数据
const transcript = await step.run("transcribe", async () => {
  return { text: hugeString }; // 步骤输出有大小限制!
});

// ✅ 推荐做法:写入文件,返回路径
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

通过launchd托管工作进程

xml
<!-- ~/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:
bash
launchctl load ~/Library/LaunchAgents/com.you.inngest-worker.plist
xml
<!-- ~/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>
加载配置:
bash
launchctl load ~/Library/LaunchAgents/com.you.inngest-worker.plist

What 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
  1. Docker启动 → Inngest服务启动,加载持久化的SQLite状态
  2. launchd启动 → 工作进程启动并注册函数
  3. 所有未完成的函数会从最后完成的步骤恢复执行

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 }
    .
  1. @inngest/ai
    是必需的peer依赖。
    仅执行
    bun add inngest
    是不够的,SDK启动时会导入
    @inngest/ai
    ,缺少会报错
    Cannot find module '@inngest/ai'
    ,务必同时安装两个包。
  2. Docker到宿主机的网络问题。 如果Inngest运行在Docker中,工作进程运行在宿主机,服务端无法访问
    localhost:3111
    ,需要在docker run命令中添加
    --sdk-url http://host.docker.internal:3111/api/inngest
    。该配置仅适用于Docker Desktop/OrbStack,Linux Docker需要添加
    --add-host=host.docker.internal:host-gateway
  3. k8s服务命名问题: 永远不要将Service命名为和二进制文件同名,
    INNGEST_PORT
    环境变量冲突会导致容器崩溃。
  4. 步骤输出大小限制: 步骤返回值尽量精简,大数据使用凭证模式传递。
  5. 工作进程重新注册: Inngest服务重启后,工作进程需要重新注册,可以重启工作进程或访问注册端点。
  6. 触发器漂移: 函数在启动时注册触发器,如果代码中修改了触发器但服务端仍有旧状态,旧触发器会继续生效,建议实现审计逻辑或同时重启服务端和工作进程。
  7. INNGEST_DEV=1
    本地开发必需配置,否则工作进程会尝试向Inngest Cloud注册。
  8. GPU任务并发设置为1: 转录、推理等会占满GPU的任务需要配置
    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
  • Inngest仪表盘可通过http://localhost:8288访问
  • 工作进程在仪表盘的函数 tab 中显示为已注册
  • 发送测试事件,可在仪表盘中看到函数执行过程
  • 函数执行中途杀死工作进程,重启后函数从最后完成的步骤恢复
  • (层级2+)重启Docker,Inngest状态保留
  • (配置launchd后)重启Mac,工作进程和Inngest都自动恢复运行

Setup Script (curl-first)

部署脚本(curl一键安装)

For automated setup, the user can run:
bash
curl -sL joelclaw.com/scripts/inngest-setup.sh | bash
Or with a specific tier:
bash
curl -sL joelclaw.com/scripts/inngest-setup.sh | bash -s -- 2
The script is idempotent, detects existing state, and scaffolds a worker with typed events.
如需自动化部署,用户可执行:
bash
curl -sL joelclaw.com/scripts/inngest-setup.sh | bash
指定部署层级:
bash
curl -sL joelclaw.com/scripts/inngest-setup.sh | bash -s -- 2
脚本是幂等的,会自动检测现有状态,并生成带类型事件的工作进程脚手架。

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
DecisionChoiceKey TradeoffLink
Workflow engineInngest (self-hosted)Step-level durability vs complexity. Cron+scripts has no per-step retry.ADR-0010
Container runtimeColima (VZ framework)Replaces Docker Desktop. Free, headless, less RAM.ADR-0029
k8s for 3 containersYes (k3d → Talos)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 runtimeBun + HonoFaster cold start than Node. Hono = minimal HTTP. launchd KeepAlive for persistence.Practical choice
Step data patternClaim-check (file path)Step outputs have size limits. Write large data to disk, pass path between steps.Inngest docs
Trigger auditingHeartbeat cron auditorSilent trigger drift broke promote function for days. Now audited every 15 min.ADR-0037
本指南的架构基于一系列架构决策记录,可按需查看权衡上下文。
ADR-0010 → ADR-0029 → 当前状态
决策选择核心权衡链接
工作流引擎Inngest(自托管)步骤级持久化 vs 复杂度。Cron+脚本没有步骤级重试能力。ADR-0010
容器运行时Colima(VZ框架)替代Docker Desktop,免费、无UI、内存占用更低。ADR-0029
3个容器是否用k8s是(k3d → Talos)380MB开销用于协调循环+未来多节点扩展。Docker Compose没有自愈能力。joel-deploys-k8s
服务命名
inngest-svc
而非
inngest
k8s注入
INNGEST_PORT
环境变量,二进制期望整数但得到URL。
踩坑经验总结
工作进程运行时Bun + Hono冷启动比Node更快,Hono是轻量HTTP框架,launchd的KeepAlive实现持久化。实践最优选择
步骤数据模式凭证模式(文件路径)步骤输出有大小限制,大数据写入磁盘,步骤间传递路径。Inngest官方文档
触发器审计心跳定时审计静默触发器漂移曾导致升级功能中断数天,现在每15分钟审计一次。ADR-0037

Credits

致谢