Deploy Bun + Hono on Fly.io: 2026 Production Guide

May 21, 2026

Deploy Bun + Hono on Fly.io: 2026 Production Guide

Deploying a Bun + Hono app to Fly.io takes three things: a multi-stage Dockerfile, a fly.toml that defines one auto-stopping Machine, and fly deploy. This guide builds a production-grade Hono API and runs Hono on Fly.io as a single Machine that scales to zero between requests.

TL;DR

You will build a small but production-shaped Hono API on the Bun runtime, containerize it with a multi-stage Dockerfile that runs as a non-root user, and deploy it to one Fly.io Machine. The fly.toml is tuned for the real world: request-based concurrency limits, an HTTP health check, a graceful-shutdown signal, and auto_stop_machines = "stop" so the Machine costs nothing while idle. Tools: Bun 1.3.14, Hono 4.12.21, Docker, and flyctl. Budget about 25 minutes start to finish, and by the end you will have Hono on Fly.io serving live HTTPS traffic.

What you'll learn

  • Scaffold a Bun + Hono project with a clean, testable file layout
  • Build a Hono API with logging, request IDs, security headers, and a health endpoint
  • Add an explicit Bun.serve entrypoint with graceful shutdown on SIGTERM
  • Write a multi-stage Bun Dockerfile that runs as a non-root user
  • Test the container locally before touching Fly.io
  • Create a Fly.io app with fly launch and write a production fly.toml
  • Tune auto-stop machines, concurrency limits, and health checks so the app scales to zero
  • Store secrets with fly secrets and ship the app with fly deploy

Prerequisites

You need four things installed locally:

  • Bun 1.3.14 — install with curl -fsSL https://bun.com/install | bash, or brew install oven-sh/bun/bun. Check with bun --version.1
  • Docker — any recent Docker Desktop or Docker Engine. BuildKit is the default builder, so no extra flags are needed. You only need it for the local container test in Step 5.
  • flyctl — the Fly.io CLI. Install it from the official instructions and run fly auth login. This guide was verified against flyctl v0.4.49.
  • A Fly.io account with a payment method. Fly.io no longer has a permanent free tier — every organization needs a card on file, though new accounts get trial credit.2 The Machine in this guide is a shared-cpu-1x with 256 MB of RAM, which costs about $1.94/month if it runs continuously — and far less here, because it stops when idle.2

A working knowledge of TypeScript and HTTP helps, but you do not need prior Bun, Hono, or Fly.io experience.

Step 1 — Scaffold the Bun + Hono project

Create an empty directory and initialize a blank TypeScript project. The -y flag accepts every default, so bun init runs without prompts.3

mkdir fly-hono-edge && cd fly-hono-edge
bun init -y

bun init writes package.json, tsconfig.json, a root index.ts, and .gitignore. Add Hono — bun add installs the package and writes the text lockfile bun.lock, which is the reproducibility anchor for the Docker build later:

bun add hono

We will keep server code under src/, so remove the placeholder entrypoint and create the source directory:

rm index.ts
mkdir src

Open package.json and replace the scripts block. bun init adds @types/bun to devDependencies — pin it to an explicit version so installs stay reproducible:

{
  "name": "fly-hono-edge",
  "module": "src/index.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "bun run --hot src/index.ts",
    "start": "bun run src/index.ts",
    "test": "bun test"
  },
  "dependencies": {
    "hono": "^4.12.21"
  },
  "devDependencies": {
    "@types/bun": "^1.3.14"
  }
}

The bun.lock file — not the caret ranges — is what guarantees a byte-identical dependency tree, because the Docker build installs with --frozen-lockfile. Commit bun.lock to version control.

Step 2 — Build the Hono API: routes, middleware, and a health endpoint

Hono is a small web framework built on Web Standards (Request/Response), and it runs natively on Bun.4 We will keep the Hono app in its own module so it can be imported by both the server entrypoint and the test file.

Create src/app.ts:

import { Hono } from 'hono'
import type { RequestIdVariables } from 'hono/request-id'
import { logger } from 'hono/logger'
import { requestId } from 'hono/request-id'
import { secureHeaders } from 'hono/secure-headers'
import { bearerAuth } from 'hono/bearer-auth'
import { HTTPException } from 'hono/http-exception'

const startedAt = Date.now()
const region = process.env.FLY_REGION ?? 'local'
const apiToken = process.env.API_TOKEN

// Typing the app with RequestIdVariables makes c.get('requestId') type-safe.
export const app = new Hono<{ Variables: RequestIdVariables }>()

// --- Global middleware ---
app.use('*', logger())
app.use('*', requestId())
app.use('*', secureHeaders())

// --- Public routes ---
app.get('/', (c) =>
  c.json({
    name: 'fly-hono-edge',
    message: 'Bun + Hono running on Fly.io',
    region,
    requestId: c.get('requestId'),
  }),
)

// Health endpoint — wired to the fly.toml health check in Step 7.
app.get('/health', (c) =>
  c.json({
    status: 'ok',
    region,
    uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
  }),
)

app.get('/api/hello/:name', (c) => {
  const name = c.req.param('name')
  return c.json({ greeting: `Hello, ${name}!`, region })
})

// --- Protected route — the token is supplied via `fly secrets` ---
const admin = new Hono<{ Variables: RequestIdVariables }>()
admin.use('*', (c, next) => {
  if (!apiToken) {
    return c.json({ error: 'API_TOKEN secret is not configured' }, 503)
  }
  return bearerAuth({ token: apiToken })(c, next)
})
admin.get('/stats', (c) =>
  c.json({
    bunVersion: Bun.version,
    pid: process.pid,
    rssMB: Math.round(process.memoryUsage().rss / 1024 / 1024),
    region,
  }),
)
app.route('/api/admin', admin)

// --- Error handling ---
app.notFound((c) => c.json({ error: 'Not Found', path: c.req.path }, 404))

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse()
  }
  console.error(`[${c.get('requestId')}]`, err)
  return c.json({ error: 'Internal Server Error' }, 500)
})

A few production details are worth calling out. The requestId middleware tags every request with a UUID (it reuses an inbound X-Request-Id header if the client sends one) and exposes it through c.get('requestId').5 The logger middleware writes one line per request to stdout — on Fly.io that stream becomes fly logs, so you get request logs for free. secureHeaders adds a sane set of response headers such as X-Frame-Options and Strict-Transport-Security.

The /health route is deliberately cheap: it returns HTTP 200 with no I/O, which is exactly what a load-balancer health check wants. The protected /api/admin/stats route reads a bearer token from process.env.API_TOKEN. If that secret is missing the route returns 503 instead of silently running unauthenticated — a small but real safety property. region comes from FLY_REGION, a three-letter region code that Fly.io injects into every Machine's runtime environment.6

Now add a test file so you can verify behaviour without booting a server. Hono apps expose an app.request() method that takes a path and returns a Response. Create src/app.test.ts:

import { describe, expect, it } from 'bun:test'
import { app } from './app'

describe('fly-hono-edge', () => {
  it('GET /health returns ok', async () => {
    const res = await app.request('/health')
    expect(res.status).toBe(200)
    expect(await res.json()).toMatchObject({ status: 'ok' })
  })

  it('GET /api/hello/:name echoes the name', async () => {
    const res = await app.request('/api/hello/ada')
    expect(res.status).toBe(200)
    expect(await res.json()).toMatchObject({ greeting: 'Hello, ada!' })
  })

  it('protected route is 503 when API_TOKEN is unset', async () => {
    const res = await app.request('/api/admin/stats')
    expect(res.status).toBe(503)
  })
})

Run it with Bun's built-in test runner — no extra dependency needed:

bun test
 3 pass
 0 fail

Step 3 — Add an explicit Bun.serve entrypoint with graceful shutdown

Bun will auto-serve a module that has a default export with a fetch handler, but that shortcut gives you no handle on the running server. For production you want the server object so you can shut it down cleanly. Create src/index.ts:

import { app } from './app'

const port = Number(process.env.PORT ?? 8080)

const server = Bun.serve({
  port,
  fetch: app.fetch,
  idleTimeout: 30,
})

console.log(`fly-hono-edge listening on ${server.url}`)

let shuttingDown = false

async function shutdown(signal: string): Promise<void> {
  if (shuttingDown) return
  shuttingDown = true
  console.log(`Received ${signal} - draining in-flight requests...`)
  await server.stop() // resolves once in-flight requests finish
  console.log('Server stopped cleanly')
  process.exit(0)
}

process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

Bun.serve binds to 0.0.0.0 by default, which is required on Fly.io — the platform proxy reaches your app over a private network, so binding to 127.0.0.1 would make the Machine unreachable.7 The port defaults to the PORT environment variable, but passing it explicitly keeps the intent obvious. idleTimeout is in seconds (default 10, max 255); 30 seconds is comfortable for a JSON API.7

The shutdown logic is the part most tutorials skip. server.stop() without arguments stops accepting new connections and resolves once in-flight requests finish; passing true would terminate connections immediately.7 Registering handlers for both SIGTERM and SIGINT means the app drains cleanly whether it is stopped by Fly.io, by Docker, or by Ctrl+C during local development.

Start the server locally to confirm it boots:

bun run dev
fly-hono-edge listening on http://localhost:8080/

In another terminal, curl http://localhost:8080/health returns JSON like {"status":"ok","region":"local","uptimeSeconds":3}. Press Ctrl+C to stop the server — you will watch the full graceful-shutdown sequence run inside the container in Step 5.

Step 4 — Write a multi-stage Bun Dockerfile

Fly.io deploys OCI container images, so the app needs a Dockerfile. A multi-stage build keeps the final image small by separating dependency installation from the runtime layer.8 Create Dockerfile:

# syntax=docker/dockerfile:1

# ---------- base ----------
FROM oven/bun:1.3.14-slim AS base
WORKDIR /app

# ---------- dependencies (production only) ----------
FROM base AS deps
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

# ---------- runtime ----------
FROM base AS release
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json bun.lock ./
COPY src ./src
USER bun
EXPOSE 8080
ENTRYPOINT ["bun", "run", "src/index.ts"]

The base image is oven/bun:1.3.14-slim — the Debian-slim variant of the official Bun image. Bun publishes debian, slim, alpine, and distroless variants for every release; slim is the pragmatic default because it keeps a shell for debugging while staying small. You can confirm the exact tag at hub.docker.com/r/oven/bun/tags.

bun install --frozen-lockfile --production installs exactly what bun.lock records and skips devDependencies — so @types/bun is never shipped to production.8 The deps stage is cached independently of your source code, so editing a route does not re-run bun install.

USER bun is important: the oven/bun images ship a non-root bun user, and running as it limits the blast radius if the process is ever compromised.8 EXPOSE 8080 is documentation that Fly.io reads during fly launch to set the internal port. The ENTRYPOINT runs the server file directly.

Add a .dockerignore so the build context stays lean and test files never reach the image:

node_modules
.git
.gitignore
Dockerfile
.dockerignore
fly.toml
README.md
**/*.test.ts
.env
.env.*

Keep bun.lock out of .dockerignore — the deps stage copies it, and the build fails without it.

Step 5 — Test the container locally

Never let Fly.io be the first place a container runs. Build and run it locally first:

docker build -t fly-hono-edge .
docker run --rm -p 8080:8080 fly-hono-edge

You should see fly-hono-edge listening on http://localhost:8080/. Hit the public routes:

curl localhost:8080/
curl localhost:8080/api/hello/ada
curl localhost:8080/health

The protected route returns 503 because no API_TOKEN is set inside the container yet:

curl -s localhost:8080/api/admin/stats
# {"error":"API_TOKEN secret is not configured"}

That is the expected, safe behaviour — the route refuses to serve until the secret exists. Stop the container with Ctrl+C; the graceful-shutdown handler logs Server stopped cleanly before exiting. If the container behaves here, it will behave on Fly.io, because Fly.io runs the exact same image.

Step 6 — Create the Fly.io app with fly launch

fly launch inspects the project, creates a Fly.io app, and writes a fly.toml. Because a Dockerfile already exists in the directory, fly launch uses it instead of generating one. Pass --no-deploy so you can review and edit fly.toml before the first deploy:

fly launch --no-deploy

flyctl will prompt for an app name and a primary region, and ask whether to tweak settings. Accept a name (it must be globally unique, so Fly.io may append a suffix) and pick a region close to your users — this guide uses ord (Chicago). Decline any offer to add a database; this app does not need one.

When the command finishes you have a fly.toml. It already contains an [http_service] block with internal_port = 8080 — Fly.io read that from your Dockerfile's EXPOSE line. The next step replaces that file with a production-tuned version.

Step 7 — Configure fly.toml for production: auto-stop, concurrency, and health checks

This is where a hobby deploy becomes a production deploy. Open fly.toml and make it match the following — keep the app value that fly launch generated for you:

app = "fly-hono-edge"
primary_region = "ord"

# Fly sends this signal on stop/deploy; the app drains on SIGTERM.
kill_signal = "SIGTERM"
# Seconds to wait for a graceful exit before a hard kill (default 5, max 300).
kill_timeout = 10

[build]
  # Optional: flyctl already looks for ./Dockerfile by default.
  dockerfile = "Dockerfile"

[env]
  NODE_ENV = "production"
  PORT = "8080"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 0

  [http_service.concurrency]
    type = "requests"
    soft_limit = 200
    hard_limit = 250

  [[http_service.checks]]
    grace_period = "3s"
    interval = "15s"
    method = "GET"
    timeout = "4s"
    path = "/health"

[[vm]]
  size = "shared-cpu-1x"
  memory = "256mb"

Each block earns its place:

Scale to zero. auto_stop_machines = "stop" tells the Fly Proxy to stop the Machine when the app is idle, and auto_start_machines = true starts it again on the next request. With min_machines_running = 0, the Machine genuinely scales to zero — a stopped Machine bills only for its root filesystem at $0.15 per GB per 30 days, not for CPU or RAM.92 Note that auto_stop_machines takes the string values "off", "stop", or "suspend" — the old boolean true that you will find in older blog posts is no longer the documented form, and "suspend" resumes faster than "stop" at the cost of holding more state.10

Concurrency. type = "requests" is recommended for HTTP apps because the Fly Proxy can pool and reuse connections; the default "connections" counts raw TCP sockets instead.10 soft_limit is the threshold at which the proxy starts steering traffic to other Machines (and the figure it uses to decide a Machine is idle); hard_limit is where it stops sending new traffic entirely.10

Health checks. The [[http_service.checks]] block makes the Fly Proxy GET /health every 15 seconds and expect a 2xx response.10 grace_period = "3s" gives the Machine time to boot before the first check — Bun and Hono start in well under a second, so three seconds is generous. One caveat from the Fly.io docs: an HTTP check does not follow 301/302 redirects, so if your app force-redirected /health to HTTPS the check would fail. This app returns 200 directly, so there is nothing to worry about.10

Graceful shutdown. The top-level kill_signal defaults to SIGINT, which the Fly.io docs note triggers a hard shutdown on most apps; overriding it to SIGTERM gives the softer, less disruptive shutdown this app is built for.10 That pairs with the SIGTERM handler from Step 3. kill_timeout = 10 gives the app ten seconds to drain in-flight requests before Fly.io forces the Machine down.10

Machine size. The [[vm]] block pins a shared-cpu-1x Machine with 256 MB of RAM. Pinning it matters for predictable scale-from-zero behaviour — without a [[vm]] section, fly deploy will not enforce a size.10

Step 8 — Store secrets with fly secrets

The [env] block in fly.toml is for non-sensitive values only — it is committed to git in plain text. The API token belongs in Fly.io's encrypted secret store instead. Set it with fly secrets set:

fly secrets set API_TOKEN="$(openssl rand -hex 24)"

Secrets are exposed to the app as environment variables, exactly like [env] values, but they are encrypted at rest and never printed back. fly secrets list shows secret names and digests, never their values. If the app were already running, setting a secret would trigger a rolling restart so the new value takes effect; because nothing is deployed yet, Fly.io simply stages it for the first deploy.11

If you want to verify the protected route after deploy, keep a copy of the generated token — openssl rand -hex 24 prints it once. You can always rotate it later by running fly secrets set again.

Step 9 — Deploy with fly deploy

Everything is in place. Ship it:

fly deploy

fly deploy builds the Docker image (using a remote builder by default), pushes it, and rolls the Fly Machine onto the new release.12 The first build downloads the oven/bun base image, so expect a minute or two; later deploys reuse cached layers and are much faster.

When it finishes, flyctl prints the app URL. Open it in a browser:

fly apps open

Verification

Confirm the deployment from the command line. Replace fly-hono-edge with your actual app name:

curl https://fly-hono-edge.fly.dev/health
{"status":"ok","region":"ord","uptimeSeconds":2}

A populated region field that matches your primary_region proves the request was served by the Fly Machine, not by something local. Check the other routes:

curl https://fly-hono-edge.fly.dev/
curl https://fly-hono-edge.fly.dev/api/hello/ada

Now exercise the secret. Without a token the protected route returns 401; with the token you saved in Step 8 it returns 200 and the stats payload:

# 401 Unauthorized - no credentials
curl -s -o /dev/null -w '%{http_code}\n' https://fly-hono-edge.fly.dev/api/admin/stats

# 200 OK - authorized
curl -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  https://fly-hono-edge.fly.dev/api/admin/stats

Inspect the Machine and stream its logs:

fly status
fly logs

In fly logs you will see one line per request from the Hono logger middleware. Leave the app untouched for a few minutes and fly status will show the Machine in a stopped state — that is auto_stop_machines doing its job. The next curl wakes it again, and fly logs will show the cold start followed by your request. The app is now live, costs nothing while idle, and drains cleanly on every deploy.

Troubleshooting

The deploy succeeds but the Machine never becomes healthy. Almost always the app is not listening where Fly.io expects. Confirm internal_port in fly.toml, the PORT env var, and the port your code passes to Bun.serve are all the same value (8080 here). Also confirm the app binds 0.0.0.0Bun.serve does by default, so do not override hostname to localhost.

Health checks keep failing. Run curl https://APP.fly.dev/health directly. If it returns anything other than a 2xx status the check will fail. A too-short grace_period is the other common cause: if the check fires before the app finishes booting it records a failure. Three seconds is plenty for Bun + Hono, but a heavier app may need more.

bun install --frozen-lockfile fails inside the Docker build. This means bun.lock is out of sync with package.json, or it is missing from the build context. Run bun install locally to regenerate the lockfile, commit it, and make sure bun.lock is not listed in .dockerignore.

The Machine never stops, or never starts. If it never stops, check that auto_stop_machines is the string "stop" — not the deprecated boolean true from older guides — and that min_machines_running is 0. If it never starts, make sure auto_start_machines = true. The Fly.io docs recommend keeping autostop and autostart either both enabled or both disabled.9

The first request after idle is slow. That is the expected cost of scaling to zero — Fly.io must start the stopped Machine before serving. For a faster wake, switch auto_stop_machines to "suspend", which resumes from a saved state. If you cannot tolerate any cold start, set min_machines_running = 1; that keeps one Machine running and costs roughly $1.94/month.2

Next steps and further reading

You now have a repeatable pattern for running Hono on Fly.io: a containerized Bun + Hono app, a production fly.toml, and a one-command deploy. From here you can scale to multiple regions by adding Machines with fly scale count, attach a managed Postgres or Upstash Redis from the Fly.io extensions catalog, or shrink the image further. Our walkthrough of distroless container images for production builds shows the same multi-stage discipline taken one step further — oven/bun:1.3.14-distroless drops the shell entirely for a smaller attack surface.

For observability, wire structured request logs into a backend rather than reading fly logs by hand — the techniques in our guide to edge observability with structured logs and Sentry port cleanly to a Fly.io app. And if the next thing you build is an API that other services authenticate against, our production TypeScript server with OAuth and Streamable HTTP tutorial is a natural follow-on from the bearer-token pattern shown here.

The authoritative references for everything in this guide are the Fly.io app configuration reference, the Bun HTTP server docs, and the Hono documentation.

Footnotes

  1. Bun installation and current version. Bun 1.3.14 is the latest stable release (npm bun dist-tag latest, published 2026-05-13). https://bun.com/docs/installation

  2. Fly.io Resource Pricing — a running shared-cpu-1x 256 MB Machine is $0.0027/hour (~$1.94/month); stopped Machines bill only for root filesystem storage at $0.15/GB per 30 days; all organizations require a payment method. https://fly.io/docs/about/pricing/ 2 3 4

  3. bun init reference — -y accepts defaults and scaffolds a blank TypeScript project non-interactively. https://bun.com/docs/runtime/templating/init

  4. Hono on Bun — getting started guide. Hono 4.12.21 is the current release. https://hono.dev/docs/getting-started/bun

  5. Hono Request ID Middleware — generates a per-request UUID via crypto.randomUUID(), reuses an inbound X-Request-Id header, and exposes the value through c.get('requestId'). https://hono.dev/docs/middleware/builtin/request-id

  6. Fly.io — The Machine Runtime Environment. The three-letter region code is stored in the FLY_REGION environment variable on every started Machine. https://fly.io/docs/machines/runtime-environment/

  7. Bun HTTP server (Bun.serve) — hostname defaults to 0.0.0.0, port defaults to the PORT env var, idleTimeout is in seconds (default 10, max 255), and server.stop() drains in-flight requests while server.stop(true) force-closes connections. https://bun.com/docs/runtime/http/server 2 3

  8. Containerize a Bun application with Docker — multi-stage build, bun install --frozen-lockfile --production, and running as the non-root bun user. https://bun.com/guides/ecosystem/docker 2 3

  9. Fly.io — Automatically stop and start Machines. auto_stop_machines accepts "off", "stop", or "suspend". https://fly.io/docs/launch/autostop-autostart/ 2

  10. Fly.io App configuration (fly.toml) reference — [http_service], [http_service.concurrency], [[http_service.checks]], [[vm]], kill_signal, and kill_timeout. https://fly.io/docs/reference/configuration/ 2 3 4 5 6 7 8

  11. Fly.io — Secrets. Encrypted environment variables; setting a secret on a running app triggers a restart. https://fly.io/docs/apps/secrets/

  12. Fly.io — Deploy an app and fly launch. fly launch --no-deploy creates the app and fly.toml without deploying; fly deploy builds and releases. https://fly.io/docs/launch/deploy/


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.