Self-Hosting Inngest on a $12 VPS: The Stack the Docs Don't Ship

Share

Our agent fleet runs on durable workflows. Every scheduled cron, every suspended human-review gate, every retry after a flaky API call rides on Inngest. When we outgrew the Cloud Hobby tier's concurrency and cron limits, the choice was pay for Pro or self-host the open-source server. We picked self-hosting, and found out the hard way that Inngest ships excellent Docker images and thorough docs, but nothing that assembles them into a production stack. No TLS story, no persistent Postgres wiring, no answer for how workers register without the Cloud sync API.

So we built the missing deploy button and open-sourced it: inngest-vps. One script provisions an AWS Lightsail box with Terraform and brings up Inngest OSS, Postgres 17, Redis 7, and automatic HTTPS in Docker Compose. This post walks through the decisions in that repo, including the ones we got wrong first.

The problem: six deployment paths, zero complete stacks

Inngest's deployment docs list integrations for Vercel, Render, DigitalOcean, and Cloudflare Pages. All of them keep the orchestrator in Inngest Cloud and only move your worker functions. That solves a different problem. If Hobby limits are what you're escaping, the orchestrator itself has to move.

The official self-hosting guide gets you a Docker image and configuration flags. What lands on you:

  • A VM with a static IP and a firewall
  • Postgres, because the default SQLite storage lives and dies with the container
  • Redis for the queue and run state
  • TLS termination and a public URL your workers can reach
  • Worker registration, which works differently than Cloud in a way nobody warns you about
  • Backups, key rotation, and log rotation once it's real

None of that is exotic. It's also nobody's favorite afternoon, and every piece has one or two sharp edges we document below.

Constraints we set for ourselves

We sized this for a solo operator or a small team. That meant three hard constraints.

One cheap box. The whole stack fits a Lightsail small_2_0 instance: 2 GB RAM, 1 vCPU, listed at $12 a month on AWS Lightsail's pricing page at the time of writing. Not Kubernetes, not RDS, not a managed Redis. If your scale genuinely needs those, the official self-host path with your own infrastructure is the better fit.

Real persistence. Run state is the entire value of a durable workflow engine. SQLite inside the container fails the "what happens when the container is recreated" test, so Postgres is non-negotiable even on a small box.

Reproducible from a clean laptop. aws login, one script, DNS record, done. Terraform owns the instance, the static IP, and the firewall, so tearing it down and rebuilding is boring by design.

The stack, service by service

Here is the Inngest service itself, pinned to a specific release rather than latest, with its config mounted read-only:

  inngest:
    image: inngest/inngest:v1.27.0
    restart: unless-stopped
    command: inngest start --config /etc/inngest/inngest.yaml
    volumes:
      - ./inngest.yaml:/etc/inngest/inngest.yaml:ro
    environment:
      INNGEST_EVENT_KEY: ${INNGEST_EVENT_KEY}
      INNGEST_SIGNING_KEY: ${INNGEST_SIGNING_KEY}
      INNGEST_POSTGRES_URI: postgres://inngest:${PG_PASSWORD}@postgres:5432/inngest
      INNGEST_REDIS_URI: redis://redis:6379
      INNGEST_QUEUE_WORKERS: ${INNGEST_QUEUE_WORKERS:-200}
      INNGEST_POLL_INTERVAL: ${INNGEST_POLL_INTERVAL:-60}
      INNGEST_LOG_LEVEL: ${INNGEST_LOG_LEVEL:-info}
      INNGEST_JSON: "true"

Three decisions worth explaining:

The version pin. inngest/inngest:v1.27.0, not latest. A workflow orchestrator is the last place you want a surprise upgrade at 3 a.m. because restart: unless-stopped pulled a new image after a reboot. Upgrades happen when a human bumps the tag and watches the deploy.

Keys are generated, not issued. Self-hosted Inngest has no account system. You mint INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY yourself with openssl rand -hex, and every worker app must carry the same pair. The repo's .env.example documents the exact commands. This inverts the Cloud mental model where keys come from a dashboard, and it means key rotation is your job. There's a script for that in the repo too, because rotating a shared key across a server and several workers by hand is exactly the kind of task that gets skipped.

The config file is generated. inngest.yaml is written by a sync script rather than edited by hand, for reasons that get their own section below.

Every service also carries an explicit memory limit. The comment at the top of the compose file states the budget: the limits sum to roughly 1.75 GB, leaving headroom for the kernel and Docker itself on a 2 GB box. Without limits, one bad day in Postgres takes down the queue with it.

TLS: four lines if you let it be

TLS is where self-hosting guides usually sprout a second guide. Caddy makes it nearly disappear:

{$INNGEST_DOMAIN:inngest.example.com} {
	encode gzip
	reverse_proxy inngest:8288
}

Point an A record at the box with the Cloudflare proxy off (grey cloud), and Caddy obtains and renews Let's Encrypt certificates on its own. The one trap: if the record is orange-clouded, Let's Encrypt's challenge hits Cloudflare instead of Caddy and issuance fails. You either grey-cloud the subdomain or switch to Cloudflare origin certificates. The Caddyfile carries a commented-out block for the second option, so the choice is a comment toggle rather than a research project.

The part nobody documents: worker registration

This is the sharp edge that cost us the most time. Inngest Cloud has a REST endpoint your deploy pipeline calls to say "here's my app, sync it." The open-source server does not. Call the same endpoint and you get a 501.

Self-hosted OSS registers apps the other way around: the server polls your workers. You list each worker's serve endpoint in inngest.yaml, and the server fetches function definitions from them on an interval. So "deploying a new worker" means editing a config file on the VPS and reloading the server. That extra step is easy to skip, and skipping it is why functions "never appear" for most people who try this.

The repo turns that into a one-liner. You keep a sync-apps.conf with one app-id|url pair per worker, and ./scripts/sync-apps.sh probes each URL, regenerates inngest.yaml, and reloads the container. The probe step matters: a typo'd URL fails loudly at sync time instead of silently at poll time. It's still a real operational cost compared to Cloud, where a deploy hook does this for you, and it belongs on the tradeoffs ledger below.

Two details from running this in production with a Mastra app:

Serve paths differ by framework. Next.js and Express apps conventionally serve Inngest at /api/inngest. Mastra's server reserves the /api prefix for itself and exposes Inngest at /inngest. The sync config takes whatever path your app actually serves, but if apps aren't appearing, this is the first thing to check.

Health is observable. The server's own healthcheck keys off the built-in doctor command:

    healthcheck:
      test: ["CMD", "inngest", "alpha", "doctor", "healthcheck"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

Caddy's depends_on waits for that check, so the public URL only comes up once the orchestrator is actually ready, not merely running.

What you give up

Self-hosting is not a free upgrade, and pretending otherwise is how people end up resenting their infrastructure. The honest ledger:

You lose managed high availability. This is one box. If it dies, workflows pause until it's back. Inngest's durability model means runs resume rather than vanish, and Terraform plus the backup script make rebuilds fast, but Cloud's uptime is someone else's pager and this design makes it yours. For our workload, content pipelines and scheduled agents, minutes of downtime is an inconvenience. If your workflows sit in a checkout path, weigh this differently.

You own upgrades. The version pin that protects you from surprise upgrades also means nobody upgrades the server unless you do. Watch the releases.

You own backups. The repo crons pg_dump on the VPS. Restores are on you to test. An untested backup is a hope, not a backup.

Observability is the dashboard, not a platform. The OSS server ships the full run-inspection UI, which covers day-to-day debugging well. What you don't get is Cloud's longer retention and account-level views. We deep-link every failure notification to the run's URL in the self-hosted dashboard, which has been enough in practice.

Some Cloud conveniences don't exist. No sync API as covered above, no managed key issuance, no team access controls in front of the dashboard. Anything sensitive sits behind whatever auth you put in front of it.

Worker registration is a standing chore, not a one-time setup. Every new worker app means touching the sync config and reloading the server. The script makes it one command, but Cloud makes it zero; multiply by how often you expect to add services before deciding that trade is fine.

If most of that list reads as "fine, that's the job," self-hosting will suit you. If it reads as a second job, stay on Cloud and pay for Pro when you outgrow Hobby. Both are sane choices; the mistake is drifting into one by default.

Operating it after day one

Provisioning is the fun part. The repo also carries the unglamorous second week, because that's where self-hosting quietly fails for most teams.

Backups run on the box. A cron on the VPS calls pg_dump through scripts/backup-pg.sh, so run history survives the worst realistic failure, which is the disk, not the datacenter. The part the script cannot do for you: restore one on purpose before you need to. An untested backup is a hope, not a backup.

Key rotation is scripted because shared keys rot. The event key and signing key are shared between the server and every worker, which means rotating them is a coordinated, multi-system change: mint new keys, update the server's env, update every worker's env, redeploy in the right order, verify nothing is still presenting the old pair. Done by hand, that's five chances to leave one system behind. scripts/rotate-inngest-keys.sh walks the whole sequence, reads worker URLs from a gitignored config file, and never echoes a key value to the terminal, so a rotation doesn't leave secrets in your shell history.

Logs are bounded. Every service in the compose file caps json-file logging at three 10 MB files. On a 2 GB box with a modest disk, unbounded Docker logs are a slow-motion outage; the cap makes log growth a solved problem instead of a monitoring item.

Upgrades are a tag bump. New Inngest release: edit the image tag, rerun the install script, watch the healthcheck go green. The doctor-based healthcheck from earlier is what makes this safe to do at lunch instead of at midnight, since a bad upgrade fails the check and never receives traffic from Caddy.

Cost, concretely

The small_2_0 bundle on AWS Lightsail's pricing page lists at $12 a month, static IP included while attached. That is the entire recurring bill for the orchestrator: Postgres and Redis live on the same box inside the memory budget above. The repo defaults INNGEST_QUEUE_WORKERS to 200. In our own deployment, running the crons and content workflows behind this blog, that default has not been the bottleneck; treat that as one team's experience, not a benchmark. Your ceiling depends on what your functions do, and the compose limits give you an honest early signal: if Inngest starts brushing its 768 MB cap, you've outgrown the small box before anything falls over mysteriously.

Where to start

The repo is MIT-licensed: github.com/PracticalWorks/inngest-vps. Clone it, run ./scripts/init.sh, set your domain, and ./scripts/up.sh does the rest. The README covers the full command surface, both Cloudflare TLS modes, and the worker registration model in more depth.

We run this exact stack under the agent fleet that wrote and fact-checked this post, which is the standard we hold recommendations to: if we suggest it for your infrastructure, it's because it already survived ours.

Hire us to build it →

Read more