DevOps

How to Dockerize an Astro Site with Bun for Production

A hands-on guide to running an Astro site in Docker with Bun: a four-stage Dockerfile, a non-root runtime user, health checks, and CI publishing to GHCR.

Quick answer

Yes, you can run an Astro site in production Docker with Bun instead of Node, and it’s a genuinely good combination: one runtime binary handles install, build, and serve, and it’s noticeably faster than an npm-based image. There’s no Bun-specific Astro adapter, though. You still build with @astrojs/node (or whichever adapter you use) and run the resulting server with the bun binary, since Bun is Node-compatible rather than a separate deployment target.

The pattern that holds up in production is a four-stage Dockerfile (dependencies, build, production dependencies, runtime), a non-root user, and a real health check. I run exactly this for this site, so everything below is what actually happens on every push to main, not a theoretical example.

Why Bun instead of plain Node here

Bun installs faster than npm or pnpm, and using the same binary for bun install, bun run build, and bun server.mjs means the image only needs one runtime instead of Node plus a separate package manager. In practice the install step is where most of the time savings show up; Vite’s bundling still dominates the actual build time, so don’t expect a build that finishes in seconds just because Bun is involved.

The trade-off is compatibility. Bun’s Node compatibility layer is very good but not perfect, and a handful of packages still behave differently or fail outright under Bun, including some Astro integrations in specific configurations. Before committing to Bun in CI, actually run bun install && bun run build inside the target Bun Docker image, not just locally on your machine’s Bun install. A build that works on your laptop’s Bun version can still fail in a clean container if a dependency’s postinstall script or native binding assumes Node.

The four-stage Dockerfile

Four stages keep the final runtime image small and the build cache useful:

# syntax=docker/dockerfile:1

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

FROM oven/bun:1 AS builder
WORKDIR /app
ENV NODE_ENV=production \
    NODE_OPTIONS=--max-old-space-size=4096
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build

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

FROM oven/bun:1 AS runtime
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000

COPY --from=production-deps --chown=1000:1000 /app/node_modules ./node_modules
COPY --chown=1000:1000 package.json bun.lock server.mjs ./
COPY --from=builder --chown=1000:1000 /app/dist ./dist

USER 1000:1000
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD bun -e "fetch('http://127.0.0.1:3000/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["bun", "server.mjs"]

A few details here matter more than they look:

  • package.json and the lockfile are copied before the rest of the source, in both the deps and production-deps stages. That’s what lets Docker reuse the install layer when you change application code without touching dependencies. It’s one of the biggest free wins in any Dockerfile.
  • --frozen-lockfile fails the build instead of silently updating bun.lock when it’s out of sync with package.json. You want that failure in CI, not a surprise dependency bump in production.
  • NODE_OPTIONS=--max-old-space-size=4096 in the builder stage exists because Astro builds with a reasonably large content collection can hit V8’s default heap limit on smaller CI runners. If your build gets killed with no clear error, this is worth trying before you start suspecting your code.
  • production-deps is a separate, fresh install with --production, not a prune of the builder’s node_modules. Pruning after the fact is fragile: it’s easy to leave stray files behind from build-time-only packages. A clean install with --production only pulls what actually runs at runtime.

Why static output plus an adapter, not full SSR

Most of this site is fully static. astro.config.mjs sets output: "static" and adds the Node adapter only so a small number of routes (in my case, a single contact-form endpoint) can opt out of prerendering with export const prerender = false. Everything else builds to plain HTML at build time and gets served straight from disk.

That combination is worth knowing about because it’s easy to assume “I added an adapter” means “my whole site is now server-rendered on every request.” It doesn’t. You get the performance and simplicity of static output everywhere except the handful of routes that genuinely need to run code per request, and the container only has to do real work for those.

A thin server wrapper in front of the adapter

@astrojs/node’s standalone mode ships its own listener, but if the container sits behind a reverse proxy that terminates TLS (Traefik, Nginx, Caddy, a cloud load balancer), it’s worth disabling that auto-start and wrapping the adapter’s request handler in your own tiny HTTP server instead:

import { createServer } from "node:http";

process.env.ASTRO_NODE_AUTOSTART = "disabled";
const { handler } = await import("./dist/server/entry.mjs");

const server = createServer((req, res) => {
  // Behind a reverse proxy, the adapter only ever sees plain http:// even
  // though the browser connected over https://. Trust X-Forwarded-Proto
  // here, but only from the private network your proxy actually runs on.
  if (isFromTrustedProxy(req.socket.remoteAddress)) {
    const proto = req.headers["x-forwarded-proto"];
    if (proto === "https") {
      Object.defineProperty(req.socket, "encrypted", { value: true, configurable: true });
    }
  }
  handler(req, res);
});

server.listen(process.env.PORT || 3000, process.env.HOST || "0.0.0.0");

The reason this matters in practice: Astro’s built-in CSRF protection compares the request’s Origin header against the protocol it thinks the request arrived on. Behind a proxy, the adapter sees http:// internally while the browser sent https://, and every form submission gets rejected with “Cross-site POST form submissions are forbidden” even though nothing is actually wrong. Trusting X-Forwarded-Proto, scoped to your proxy’s own network range rather than blindly, fixes it without weakening the CSRF check itself.

Health checks, permissions, and image size

  • Use bun -e "fetch(...)" for the HEALTHCHECK instead of installing curl or wget. Bun is already in the image, so this adds a real check with zero extra packages.
  • Run as a non-root numeric UID (USER 1000:1000) and --chown the files you copy in. There’s no good reason for a web server process to run as root inside its container.
  • Keep the runtime stage’s COPY list short: production node_modules, the built dist/ output, package.json, the lockfile, and your entrypoint script. Nothing from the build stage’s source tree needs to make it into the final image.

Shipping it: CI and GHCR

The container-build workflow builds and pushes on every push to main, tags images with docker/metadata-action (branch, semver, and commit SHA tags), and uses BuildKit’s GitHub Actions cache (cache-from/cache-to: type=gha) so unrelated code changes don’t re-download the world. Passing the commit SHA and build time in as build args and setting them as OCI labels (org.opencontainers.image.revision, .created) means you can always trace a running container back to the exact commit that produced it, which is worth far more than it costs to set up.

If this container eventually runs inside Kubernetes instead of a single proxy-fronted host, and you add scheduled jobs alongside it, the scheduling format and operational details are different enough from a plain crontab that they’re worth reading up on separately. I wrote about the differences between crontab, Quartz, and Kubernetes CronJobs when I ran into exactly that.

This same workflow is also a good place to add a site-wide performance check instead of trusting a single manual Lighthouse run. See how Unlighthouse audits an entire site in CI if you want a budget that actually fails the build when a page regresses.

Common mistakes

  • Copying the full source before installing dependencies, which busts the Docker layer cache on every code change instead of just on dependency changes.
  • Pruning the build stage’s node_modules instead of doing a clean --production install for the runtime stage.
  • Skipping --frozen-lockfile and letting the lockfile drift silently between local development and CI.
  • Running the container as root because it was the path of least resistance.
  • Trusting X-Forwarded-* headers from any source instead of scoping that trust to your actual proxy’s network.

FAQ

Does Astro have an official Bun adapter?

No. Astro’s adapters target Node, Deno, Cloudflare, and similar platforms. Running under Bun means using an existing adapter (usually @astrojs/node) and executing its output with the bun binary instead of node, relying on Bun’s Node compatibility rather than a Bun-specific integration.

Is Bun actually faster than npm or pnpm for an Astro Docker build?

Installs are consistently faster, and using one binary for install, build, and serve simplifies the image. The build step itself is mostly Vite bundling your site, which takes roughly the same time regardless of package manager, so treat the install speed as the real win rather than expecting a dramatically shorter overall build.

Why does my Astro build fail under Bun but work fine with Node?

Usually a dependency assuming a Node-only API or native binding that Bun’s compatibility layer doesn’t fully cover yet. Test the actual build (not just the install) inside the target Bun Docker image before switching CI over, and keep a Node-based fallback path in mind if a critical integration doesn’t behave the same way under Bun.

Do I need a reverse proxy in front of a container like this?

For TLS termination in production, yes, in almost every real setup: Traefik, Nginx, Caddy, or a cloud load balancer. Just make sure your app trusts X-Forwarded-Proto correctly when it does, or you’ll hit CSRF/origin mismatches on any route that checks the request’s protocol, such as a form submission handler.

Final thought

None of this is complicated once it’s working, but a couple of these details (the memory flag, the fresh production install, the forwarded-proto trust) are exactly the kind of thing that costs an afternoon the first time and thirty seconds every time after. Get the four stages right, keep the runtime image lean and non-root, and Bun ends up being a solid, faster default for running Astro in production rather than a risky experiment.

About the author

Written by Tiago Galvão

Full Stack Developer · Portugal | Switzerland

I've been building for the web since 2001. Full stack development across Vue, Nuxt, Astro, TypeScript, C#, .NET, and PostgreSQL, among others, with a habit of writing down what actually worked and what didn't once the dust settles.

More about me →