Back to Blog
Tutorial7 min read2026-07-11

How to Deploy Fastify on PandaStack (with Postgres)

The 0.0.0.0 binding gotcha, pino logs done right, graceful shutdown, node-pg-migrate with DATABASE_URL, and a lean production Dockerfile.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Fastify is one of the few Node frameworks that feels like it was designed by people who have actually operated production servers: structured logging is built in, graceful close is built in, request validation is part of the route definition instead of bolted on. Deploying it is genuinely simple — which makes it funny that the single most common Fastify deployment failure is one missing option in one line of code.

Let's start there, then do the rest properly: logs, shutdown, Postgres, migrations, and shipping via Git push.

The one line that breaks most Fastify deploys

// works on your laptop, dead in a container
await app.listen({ port: 3000 })

Fastify binds to localhost by default. That's a sensible security default for local development, and exactly wrong inside a container, where traffic arrives on the container's network interface rather than loopback. The process runs, your logs look clean, and every health check fails because nothing is listening where the platform is knocking.

The fix:

await app.listen({
  port: Number(process.env.PORT) || 3000,
  host: '0.0.0.0',
})

Read the port from the environment while you're at it — the platform decides where to route, not your source code.

A production-shaped server

// server.js
import Fastify from 'fastify'

const app = Fastify({
  logger: true,
})

app.get('/healthz', async () => ({ status: 'ok' }))

app.get('/', async () => ({ hello: 'fastify' }))

try {
  await app.listen({
    port: Number(process.env.PORT) || 3000,
    host: '0.0.0.0',
  })
} catch (err) {
  app.log.error(err)
  process.exit(1)
}

logger: true turns on pino: one structured JSON line per request, with timings, on stdout. Resist the urge to layer morgan or winston on top. Platforms that stream your app's stdout — PandaStack streams app logs live in the dashboard — work best with exactly this shape: one parseable line per event. In development, pipe through pino-pretty locally; never install prettifiers in the production path.

The /healthz route is deliberately dependency-free. A health check that queries the database turns every database hiccup into a full restart of your app tier. Check liveness cheaply; alert on the database separately.

Graceful shutdown

Every deploy and every scale-down sends your process SIGTERM. If you ignore it, in-flight requests die mid-response and the platform eventually SIGKILLs you. Fastify's close() does the right thing — stops accepting new connections, waits for in-flight requests, then runs onClose hooks in order. You just need to wire the signal to it. The close-with-grace package (what fastify-cli's own templates use) handles the corner cases:

import closeWithGrace from 'close-with-grace'

closeWithGrace({ delay: 10000 }, async ({ err }) => {
  if (err) app.log.error(err)
  await app.close()
})

Ten seconds is enough for a web workload; anything still running after that probably belongs in a job queue anyway.

Postgres via @fastify/postgres

Fastify's plugin system means the database pool lives inside the app lifecycle rather than as a module-level global:

npm i fastify @fastify/postgres pg close-with-grace
import fastifyPostgres from '@fastify/postgres'

await app.register(fastifyPostgres, {
  connectionString: process.env.DATABASE_URL,
})

app.get('/todos', async () => {
  const { rows } = await app.pg.query(
    'SELECT id, title FROM todos ORDER BY id'
  )
  return rows
})

Because the plugin registers its own onClose hook, app.close() drains the pool during shutdown — no dangling connections after a deploy. That matters more than it sounds: PandaStack's free-tier managed Postgres allows 50 connections, and the pg default pool of 10 per instance is comfortable for one replica. If you scale out, multiply pool size by replica count and make sure the arithmetic clears the limit before Postgres starts refusing connections.

Migrations with node-pg-migrate

node-pg-migrate reads DATABASE_URL from the environment by default — zero configuration on any platform that injects it:

npm i node-pg-migrate
npx node-pg-migrate create add-todos-table

That drops a timestamped file into migrations/:

exports.up = (pgm) => {
  pgm.createTable('todos', {
    id: 'id',
    title: { type: 'text', notNull: true },
    created_at: {
      type: 'timestamptz',
      notNull: true,
      default: pgm.func('now()'),
    },
  })
}

exports.down = (pgm) => {
  pgm.dropTable('todos')
}

Apply with:

npx node-pg-migrate up

Run this as a separate step before the new version takes traffic — not in the server's boot sequence. Two replicas starting simultaneously will race the same migration, and a migration failure should be a failed release step you can read about, not a web server in a crash loop.

A Dockerfile for Fastify

Plain JavaScript Fastify needs no build step at all, which keeps the image honest:

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

If you're on TypeScript, add a build stage that runs tsc and copy dist/ into the final image — same pattern, one extra FROM. Either way, USER node: a web server has no business running as root.

Deploying on PandaStack

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x) from the [dashboard](https://dashboard.pandastack.io). Daily backups are scheduled automatically, retained per plan.
  2. 2Connect your repo as a container app. If the Dockerfile above is in the repo it gets used; without one, the Node buildpack auto-detects install and start commands (npm, yarn, pnpm, and bun are all supported).
  3. 3Attach the database to the app. DATABASE_URL is injected automatically — the same variable @fastify/postgres and node-pg-migrate already read, so there is genuinely nothing to configure.
  4. 4Add remaining environment variables in the dashboard.
  5. 5Push to your branch. The image builds with rootless BuildKit in an ephemeral Kubernetes job pod, the build log streams live, and Helm rolls it out. Deployment history gives you rollback if a release goes sideways.

Run npx node-pg-migrate up as a one-off command after the database is attached, and again on any deploy that adds migration files.

Free tier notes

On the free tier, idle apps scale to zero and cold-start on the next request, and workloads run on preemptible nodes — meaning your process will occasionally get evicted and rescheduled. This is exactly why the graceful-shutdown section above isn't optional: with SIGTERM handled properly, an eviction looks like a clean restart instead of dropped requests. Fastify boots fast enough that cold starts stay tolerable for hobby traffic, and the tier itself — 5 web services, 1 database, 300 build minutes a month — covers a real side project.

If you want to see the whole loop run, connect a Fastify 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