Back to Blog
Tutorial6 min read2026-07-11

How to Deploy Elysia (Bun) with PostgreSQL on PandaStack

Deploy an Elysia API to production: correct port binding, a lean Bun Dockerfile, Drizzle migrations, and managed Postgres wired in via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Elysia is the framework most people reach for when they commit to Bun. It's fast, the type inference is genuinely end-to-end (your client can consume the server's types via Eden), and the API surface is small. But because it runs on Bun rather than Node, most "deploy a Node app" guides get the details wrong — the base image, the lockfile, the install flags, even whether you need a build step at all. Here's the full path from bun create elysia to a live URL with a managed Postgres database behind it.

Two ways to run Elysia in production

Bun executes TypeScript directly, so unlike a Node + TypeScript project there is no mandatory compile step. That gives you two legitimate production strategies:

Option 1: run the source directly.

bun src/index.ts

Bun transpiles at startup. This is the simplest setup and what I'd recommend for most apps — one less thing to break.

Option 2: compile to a single binary.

bun build --compile --minify --target bun --outfile server src/index.ts

This bundles your app *and the Bun runtime* into one self-contained executable. Startup is fast and the runtime image can be tiny because it doesn't even need Bun installed. The trade-off: some packages with native bindings don't survive bundling cleanly, so test the binary locally before you rely on it. If it works for your dependency set, it's a great fit for scale-to-zero platforms where cold-start time matters.

This guide uses option 1, with a note on where option 2 slots in.

Bind the port correctly

The scaffold from bun create elysia app listens on a hardcoded port. In a container you need two changes: read the port from the environment, and bind to 0.0.0.0 so the platform's ingress can reach the process.

// src/index.ts
import { Elysia } from 'elysia'

const app = new Elysia()
  .get('/health', () => ({ ok: true }))
  .get('/', () => 'hello from elysia')
  .listen({
    port: Number(process.env.PORT ?? 3000),
    hostname: '0.0.0.0',
  })

console.log(`listening on ${app.server?.hostname}:${app.server?.port}`)

The /health route isn't decoration — you want a path that responds without touching the database, so the platform's health checks don't recycle your container every time the DB has a slow moment.

While you're in there, add graceful shutdown. Kubernetes-based platforms send SIGTERM before killing a pod, and Elysia exposes app.stop():

process.on('SIGTERM', async () => {
  await app.stop()
  process.exit(0)
})

Without this, in-flight requests get dropped on every deploy.

PostgreSQL with Drizzle

Drizzle is the natural ORM pairing for Elysia — same TypeScript-first philosophy, no codegen daemon. Install the runtime pieces and the migration CLI:

bun add drizzle-orm postgres
bun add -d drizzle-kit

Point everything at a single DATABASE_URL:

// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'

const sql = postgres(process.env.DATABASE_URL!, { max: 10 })
export const db = drizzle(sql)

That max: 10 matters more than it looks. Managed Postgres plans cap concurrent connections — on PandaStack's free tier the database allows 50 — so if you run a couple of app replicas plus a migration job, an unbounded pool will eat the whole budget and you'll start seeing too many clients errors under load.

Configure drizzle-kit:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './drizzle',
  dbCredentials: { url: process.env.DATABASE_URL! },
})

Generate migration SQL locally whenever the schema changes:

bunx drizzle-kit generate

For *applying* migrations in production, skip drizzle-kit entirely — it's a dev dependency you don't want in the runtime image. Drizzle ships a programmatic migrator that only needs your runtime deps:

// src/migrate.ts
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'

const sql = postgres(process.env.DATABASE_URL!, { max: 1 })
await migrate(drizzle(sql), { migrationsFolder: './drizzle' })
await sql.end()
console.log('migrations applied')

Run it with bun src/migrate.ts as a release step — before the new version takes traffic, not inside the app's startup path. Migrations inside boot code plus multiple replicas rolling out at once is how you get two processes racing on the same ALTER TABLE.

The Dockerfile

The official image is oven/bun. Note the lockfile copy pattern — newer Bun versions write a text bun.lock, older ones a binary bun.lockb, and bun.lock* catches both:

FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production

FROM oven/bun:1
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER bun
EXPOSE 3000
CMD ["bun", "src/index.ts"]

--frozen-lockfile fails the build if the lockfile is out of sync with package.json instead of silently resolving new versions — you want that failure at build time, not a surprise dependency in production.

If you went with the compiled-binary route, the runtime stage shrinks to copying one file and running it; you don't need node_modules at all.

Deploying on PandaStack

With the Dockerfile in the repo, the deploy itself is short:

  1. 1Create a managed PostgreSQL instance from the dashboard (14.x and 16.x are available).
  2. 2Connect the Git repo as a container app. PandaStack picks up the Dockerfile and builds it with rootless BuildKit in an ephemeral Kubernetes job pod — no host Docker socket involved. If you skip the Dockerfile, the Node buildpack path works too, and bun is a supported install command alongside npm, yarn, and pnpm.
  3. 3Attach the database to the app. This is the part that removes a whole class of mistakes: DATABASE_URL is injected into the container automatically. No copying connection strings between dashboards, no credentials in your repo.
  4. 4Set any remaining env vars in the app settings, then push. Every push builds and deploys, and the build logs stream live so a failed bun install is visible the moment it happens rather than after a timeout.

One behavior worth knowing: on the free tier, idle apps scale to zero and cold-start on the next request. Elysia on Bun is about the best-case workload for that model — startup is quick, especially compiled — but if your API needs to hold sub-100ms p99s at 3 a.m., that's what the paid tiers (which stay warm on stable nodes) are for.

Production checklist

  • PORT from env, hostname 0.0.0.0
  • /health route that doesn't touch the DB
  • SIGTERM handler calling app.stop()
  • postgres.js pool capped (max: 10 is a sane start)
  • Migrations via bun src/migrate.ts as a release step, never at import time
  • --frozen-lockfile in the Docker build

None of these are Elysia-specific pain — they're the standard container-deployment tax, and Elysia makes each one about three lines. If you want to see the whole loop working, spin it up on https://pandastack.io and push a commit.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also