Back to Blog
Tutorial7 min read2026-07-11

How to Deploy Hono on PandaStack (Node.js + Postgres)

Deploy Hono as a long-lived Node server: the node-server adapter, PORT and 0.0.0.0 binding, Drizzle migrations, and going live via Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Hono's whole pitch is that it runs everywhere — Cloudflare Workers, Deno, Bun, Node.js — from one codebase. That flexibility is great until deploy day, when "runs everywhere" turns into "which target am I actually shipping?" If you're deploying to a container platform, the answer is the most boring one: a long-lived Node.js server. No edge runtime quirks, no bundler tricks for Workers, just a process listening on a port.

This walks through exactly that: the Node adapter, a production build, Postgres with Drizzle, and shipping the whole thing with a Git push.

Start from the Node.js template

The Hono scaffolder asks which runtime you're targeting:

npm create hono@latest my-api

Pick nodejs. You get a src/index.ts that uses @hono/node-server, which adapts Hono's Web-standard fetch handler to Node's HTTP server. The default entry works locally but needs two changes before it's deployable:

import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()

app.get('/healthz', (c) => c.text('ok'))
app.get('/', (c) => c.json({ hello: 'hono' }))

serve({
  fetch: app.fetch,
  port: Number(process.env.PORT) || 3000,
  hostname: '0.0.0.0',
})

The changes: read the port from PORT instead of hardcoding it, and bind to 0.0.0.0. Inside a container, traffic arrives on the container's network interface, not loopback. A server bound to localhost will run happily while every health check fails and the deploy gets marked dead. This one line accounts for a depressing share of "it works on my machine but the platform says unhealthy" tickets.

Compile TypeScript for production

The template uses tsx watch for development. Don't run tsx in production — compile instead. You start faster and you find type errors at build time, not at 2am. I like tsup here because it produces a small bundled entry:

npm i -D tsup
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsup src/index.ts --format esm --clean",
    "start": "node dist/index.js"
  }
}

Plain tsc works too if you prefer unbundled output. The bundled single file matters more than it looks: on a tier that scales to zero, every cold start pays your boot time, and Hono's dependency graph is tiny to begin with. Keep it that way.

Add Postgres with Drizzle

Hono has no ORM opinion, which makes Drizzle a natural fit — it's similarly minimal.

npm i drizzle-orm pg
npm i -D drizzle-kit @types/pg

A schema:

// src/db/schema.ts
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'

export const todos = pgTable('todos', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

The client, reading the standard DATABASE_URL:

// src/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
})

export const db = drizzle(pool)

A note on max: 10: PandaStack's free-tier managed Postgres allows 50 connections. One instance with a pool of 10 is comfortable; if you later run multiple replicas, multiply pool size by replica count before you hit the ceiling.

Drizzle Kit config for migrations:

// 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! },
})

And a route that uses it:

import { db } from './db'
import { todos } from './db/schema'

app.get('/todos', async (c) => c.json(await db.select().from(todos)))

Migrations: generate locally, apply at deploy

Two Drizzle commands matter, and they are not interchangeable:

npx drizzle-kit generate   # writes SQL migration files to ./drizzle — commit these
npx drizzle-kit migrate    # applies pending migrations to DATABASE_URL

drizzle-kit push is the tempting third option — it diffs your schema straight against the database. Fine for prototyping, wrong for production: there's no migration history and a schema diff can happily drop a column.

Since drizzle-kit is a dev dependency, the cleanest production setup is a tiny programmatic migration script that needs only runtime deps:

// src/migrate.ts — compiled to dist/migrate.js
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await migrate(drizzle(pool), { migrationsFolder: './drizzle' })
await pool.end()

Run node dist/migrate.js as a separate step before the new version takes traffic — never inside the server's boot path. Two instances rolling out at once will race the same migration, and a failed migration shouldn't take the form of a crash-looping web server.

A Dockerfile for Hono on Node

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY drizzle ./drizzle
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

The drizzle/ folder ships in the image so dist/migrate.js can find the SQL files. USER node because there is no reason for a web server to run as root, ever.

Deploying on PandaStack

The flow, end to end:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x) from the [dashboard](https://dashboard.pandastack.io). Backups are scheduled daily and retained per plan.
  2. 2Connect your repo as a container app. If a Dockerfile is present it's used; otherwise the Node buildpack auto-detects your install and build commands (npm, yarn, pnpm, and bun all work).
  3. 3Attach the database to the app. DATABASE_URL is injected automatically — the exact variable your pool and migration script already read. No copying credentials between tabs.
  4. 4Add any other environment variables in the dashboard.
  5. 5Push. The image builds with rootless BuildKit in an ephemeral Kubernetes job pod, build logs stream live, and Helm rolls the deploy out. If something's wrong, deployment history gives you one-click rollback.

Run the migration script as a one-off command (or a pre-traffic release step) after the first deploy, and on any deploy that includes new files in drizzle/.

Hono and scale-to-zero are a good match

On the free tier, idle apps scale to zero and cold-start on the next request. That's a real trade-off — the first request after idle waits for boot — but it's the trade-off Hono is unusually well suited to: a minimal framework, a bundled single-file entry, and nothing heavyweight to initialize. The free tier gives you 5 web services, 1 database, and 300 build minutes a month, which covers a side project's whole lifecycle.

If you want to see the full loop, connect a Hono repo on https://pandastack.io and push.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also