Background jobs are where side projects go to die. You start with a setTimeout, graduate to a queue, then spend a weekend building retry logic, dead-letter handling, and idempotency you'll never fully trust. Inngest (https://www.inngest.com) exists to delete that weekend: you write functions that react to events, with built-in retries, steps, concurrency limits, and scheduling — and the durability is handled for you. Inngest offers a cloud product, but it can also be self-hosted, which is what we'll do. I run PandaStack; here's the setup.
The mental model
Inngest has two pieces:
- 1The Inngest server — receives events, stores state, and orchestrates function execution (retries, steps, delays). Self-hostable as a container.
- 2Your app — defines functions and exposes them at an HTTP endpoint that the Inngest server calls. This is just your existing Node/Next/Express app plus the Inngest SDK.
The server calls back into your app to run each step. Because state lives in the server, a crash mid-workflow resumes from the last completed step — that's the durability.
Step 1: Define a function in your app
// inngest/client.js
import { Inngest } from 'inngest'
export const inngest = new Inngest({ id: 'my-app' })
// inngest/functions.js
import { inngest } from './client.js'
export const welcome = inngest.createFunction(
{ id: 'send-welcome' },
{ event: 'user/created' },
async ({ event, step }) => {
await step.run('send-email', async () => {
// your email send here
return { sent: true, to: event.data.email }
})
await step.sleep('wait-a-day', '1d')
await step.run('send-tips', async () => {
return { sent: true, kind: 'tips' }
})
},
)That step.sleep('1d') is the magic — Inngest durably waits a day without holding a process open. No cron, no polling loop.
Step 2: Expose the endpoint
For an Express app:
import express from 'express'
import { serve } from 'inngest/express'
import { inngest } from './inngest/client.js'
import { welcome } from './inngest/functions.js'
const app = express()
app.use('/api/inngest', serve({ client: inngest, functions: [welcome] }))
app.listen(process.env.PORT || 8080)Step 3: Dockerfiles
Your app is a normal container:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 8080
CMD ["node", "server.js"]The self-hosted Inngest server runs from its official image. You'll deploy it as a second app on PandaStack, pointed at durable storage (Postgres) for its state.
Step 4: Deploy both on PandaStack
- 1Managed Postgres for Inngest's state: Databases → New Database → PostgreSQL. Copy its connection string.
- 2Inngest server app: deploy the official Inngest image as a container app. Set its env to use your Postgres for persistence and expose its port. (Check the Inngest self-hosting docs at https://www.inngest.com/docs for the exact env var names for your version — they evolve.)
- 3Your app: deploy your repo as a container. Set
INNGEST_BASE_URL(or the equivalent) to your Inngest server's internal URL, and register your app's/api/inngestendpoint with the server. - 4Both are Git-push deployed; both get automatic SSL if you expose them publicly.
Because both live on PandaStack, they talk over the platform network, and you manage them from one dashboard with one bill.
Step 5: Send an event
From anywhere in your app:
await inngest.send({ name: 'user/created', data: { email: 'ada@example.com' } })Inngest picks it up, runs send-welcome, retries on failure, and resumes across the one-day sleep. You get an execution log in the Inngest dashboard.
Cronjobs, the Inngest way
Inngest can also run scheduled functions:
export const nightly = inngest.createFunction(
{ id: 'nightly-report' },
{ cron: '0 2 * * *' },
async ({ step }) => {
await step.run('build-report', async () => { /* ... */ })
},
)If all you need is *one* simple scheduled task, PandaStack's built-in cronjob container is simpler — no Inngest server to run. Reach for Inngest when you need durable multi-step workflows, fan-out, concurrency control, or event-driven jobs. Use the right tool: don't stand up an orchestration server to send one nightly email.
Honest tradeoffs
- Self-hosting Inngest means running (and updating) the Inngest server yourself. Their cloud removes that. Self-host when you want data locality, cost control, or everything under one roof.
- Two apps + a database is more moving parts than a single cron container. Justified only when your job logic is genuinely workflow-shaped.
- Check the self-hosting docs for your exact version — the self-hosted surface is newer than the cloud one and changes.
Wrap-up
Inngest turns fragile background jobs into durable, retryable, step-based workflows. Self-host the server and your functions on PandaStack side by side, back the server with managed Postgres, and deploy both with Git push. For simple schedules, PandaStack's own cronjobs are enough.
Docs: https://docs.pandastack.io. Start free: https://dashboard.pandastack.io.