Type something to search...
How to deploy an Astro site with Docker and Node.js?

How to deploy an Astro site with Docker and Node.js?

To deploy an Astro site with Docker and Node.js, add the @astrojs/node adapter in standalone mode, build the project inside a Node.js image, and start the generated server with node ./dist/server/entry.mjs. The container needs HOST=0.0.0.0 so the server listens on all interfaces, and a PORT, such as 4321, that you expose and map with docker run -p. A multi-stage Dockerfile keeps the final image small and the builds fast: one stage installs dependencies and runs astro build, and the final stage copies only node_modules and dist. If your site is fully static, you don't need Node.js at runtime at all and can serve dist from an NGINX image instead.

Docker gives you the same runtime everywhere: on your laptop, in CI and on any host that runs containers, from a single VPS to Google Cloud Run, AWS or Fly.io. In this article you'll learn how to set up the Node adapter, write a production-ready multi-stage Dockerfile and .dockerignore, pass environment variables correctly, deal with sessions and health checks, run the container behind a reverse proxy, and package a static Astro site with NGINX.

Step 1: Add the Node Adapter

The Node adapter turns your on-demand routes into a Node.js HTTP server. Add it with:

npx astro add node

This installs @astrojs/node and sets it up in standalone mode:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({
    mode: 'standalone',
  }),
});

Standalone mode builds a complete server that handles your pages, API routes and static files, so the container needs nothing else. The alternative, middleware mode, exports a handler for an Express or Fastify app you write yourself; use it only if you already have such a server. The choice between adapters in general is covered in Astro adapters and which one to choose.

Build once locally to check the output:

npx astro build
dist/client/index.html
dist/client/about/index.html
dist/server/entry.mjs
dist/server/chunks/...

dist/client/ holds prerendered pages and assets, and dist/server/entry.mjs is the server. You can run it directly to test what the container will do:

HOST=0.0.0.0 PORT=4321 node ./dist/server/entry.mjs
[@astrojs/node] Server listening on
  local: http://localhost:4321
  network: http://192.168.0.101:4321

The server reads HOST and PORT from the environment at runtime, which is exactly what a container needs.

Step 2: Choose a Node.js Base Image

Astro 7.4.2 declares "node": ">=22.23.4" in its engines field, so the image must ship a recent Node.js 22 or later. The official node images on Docker Hub include tags such as node:24-slim, node:24-alpine, node:22.23.4-slim and node:lts. A few guidelines:

  • Pin a major version, such as node:24-slim, rather than node:latest, so a new major release doesn't change your runtime unexpectedly.
  • -slim images are Debian-based and small. They're a safe default.
  • -alpine images are smaller still but use musl instead of glibc, which occasionally causes problems with native modules such as sharp. Test your image processing if you choose Alpine.

Step 3: Write a .dockerignore

Before writing the Dockerfile, stop Docker from copying things it shouldn't. Create .dockerignore next to the Dockerfile:

# .dockerignore
.DS_Store
node_modules
dist
.astro
.env
.env.*
.git

Excluding node_modules and dist keeps the build context small and makes sure dependencies are installed fresh for the image's platform. Excluding .env files keeps local secrets out of the image.

Step 4: Write a Multi-Stage Dockerfile

This Dockerfile is based on the multi-stage example in the Astro docs, with a pinned image, npm ci, and a non-root user:

# Dockerfile
FROM node:24-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS prod-deps
RUN npm ci --omit=dev

FROM base AS build-deps
RUN npm ci

FROM build-deps AS build
COPY . .
RUN npm run build

FROM base AS runtime
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321

USER node
CMD ["node", "./dist/server/entry.mjs"]

What each stage does:

  1. base copies only package.json and package-lock.json. Because the source code isn't copied yet, Docker can cache the dependency layers and skip reinstalling when only your code changes.
  2. prod-deps installs runtime dependencies only.
  3. build-deps installs everything, including dev dependencies needed for the build, such as TypeScript or @astrojs/check.
  4. build copies the source and runs astro build.
  5. runtime starts from the small base again and copies in just the production node_modules and the dist folder.

USER node runs the server as the unprivileged node user that the official images provide, instead of root. CMD uses the JSON array form, so Node.js receives stop signals from Docker directly and can shut down cleanly.

A Note on Image Size

The Node adapter needs astro and @astrojs/node at runtime, and both are regular dependencies. So in a minimal project, --omit=dev saves very little: in a test project, the production install was 135 MB against 136 MB for the full install. The split pays off once you have real dev-only tooling such as test runners, linters and type checkers, so keep it, but list build-only packages under devDependencies to benefit.

Step 5: Build and Run the Image

Build the image from your project root, giving it any name:

docker build -t my-astro-site .

Run it, mapping a port on your machine to the container's port:

docker run --rm -p 8080:4321 my-astro-site

The site is now at http://localhost:8080. If you change PORT at runtime, change the mapping too:

docker run --rm -p 8080:3000 -e PORT=3000 my-astro-site

Environment Variables in a Container

This is where most Docker deployments go wrong, because Astro has two different moments when variables are read.

  • Build time: anything read through import.meta.env is inlined into the built code when npm run build runs. In Astro 6 and later it's always inlined and never coerced. That includes every PUBLIC_ variable used in client code. These values must exist during docker build.
  • Runtime: process.env in on-demand routes and secrets read through astro:env are read when the server runs. These should be passed to docker run, not baked into the image.

The Node adapter's docs are explicit that neither Astro nor the adapter loads .env files for you at runtime. Pass runtime values with -e or --env-file:

docker run --rm -p 8080:4321 --env-file .env.production my-astro-site

For build-time values, use a build argument and expose it as an environment variable in the build stage only:

FROM build-deps AS build
ARG PUBLIC_API_URL
ENV PUBLIC_API_URL=$PUBLIC_API_URL
COPY . .
RUN npm run build
docker build --build-arg PUBLIC_API_URL=https://api.example.com -t my-astro-site .

Don't pass secrets as build arguments: they can end up in the image history. Keep secrets for runtime. The difference between these approaches is covered in how to set environment variables in Astro, and astro:env gives you validation so a missing secret fails loudly. Note that astro:env validates secrets whenever astro:env/server is imported, which can include the build, so you may need dummy values during docker build.

Sessions and Other Local State

When you use Astro's Sessions API with the Node adapter, it stores sessions on the local filesystem by default, inside the project's Astro cache directory. In a container that storage disappears when the container is replaced, and it isn't shared between replicas. For anything beyond a single long-lived container, configure a shared driver such as Redis:

// astro.config.mjs
import { defineConfig, sessionDrivers } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
  session: {
    driver: sessionDrivers.redis({
      url: process.env.REDIS_URL,
    }),
  },
});

The Redis driver needs the ioredis package, so run npm install ioredis first; without it, the build fails with Cannot find package 'ioredis'. This object form with sessionDrivers replaced the older string-based driver: 'redis' syntax in Astro 6. If you don't use sessions at all, set session: false and the adapter won't configure storage. The same caution applies to anything else your app writes to disk, such as uploads or a SQLite file: mount a volume or use an external service. Sessions are covered in more depth in Astro sessions.

Health Checks

Orchestrators and load balancers need a URL that tells them the app is alive. Add a tiny on-demand endpoint:

// src/pages/healthz.ts
import type { APIRoute } from 'astro';

export const prerender = false;

export const GET: APIRoute = () => new Response('ok');

Then, if you run plain Docker or Docker Compose, add a HEALTHCHECK. Slim images don't include curl, but Node.js has fetch built in:

HEALTHCHECK --interval=30s --timeout=3s \
  CMD node -e "fetch('http://localhost:' + (process.env.PORT || 4321) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

Platforms such as Kubernetes or Cloud Run use their own probe settings instead; point them at the same /healthz path.

Running Behind a Reverse Proxy

The standalone server speaks plain HTTP. In production you'll usually put it behind a reverse proxy or load balancer that handles TLS, such as NGINX, Caddy, Traefik or your cloud provider's load balancer. Hashed files in dist/client/_astro/ are served with Cache-Control: public, max-age=31536000, immutable, so a CDN or proxy can cache them indefinitely.

If you really need the Node server to terminate HTTPS itself, the adapter reads a certificate and key from the SERVER_CERT_PATH and SERVER_KEY_PATH environment variables.

The standalone server also accepts request bodies up to 1 GB by default. For a public site, lower that with the adapter's bodySizeLimit option, in bytes:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({
    mode: 'standalone',
    bodySizeLimit: 10 * 1024 * 1024, // 10 MB
  }),
});

Static Sites: Serve dist With NGINX

If every page is prerendered, there's no reason to run Node.js in production. Build with Node and serve the files with NGINX:

# Dockerfile
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine AS runtime
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080

A minimal nginx/nginx.conf that serves Astro's /about/index.html style output and its 404 page:

worker_processes 1;

events {
  worker_connections 1024;
}

http {
  include /etc/nginx/mime.types;

  server {
    listen 8080;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    error_page 404 /404.html;

    location / {
      try_files $uri $uri/index.html =404;
    }
  }
}

Without an adapter, astro build writes the pages straight into dist/, not dist/client/, which is why the whole folder is copied. Run it with docker run --rm -p 8080:8080 my-astro-static.

Troubleshooting

  • The container starts but the site doesn't load: check that HOST=0.0.0.0 is set. If the server listens only on localhost inside the container, Docker's port mapping can't reach it.
  • "Cannot find module" at startup: the runtime stage is missing dependencies. Make sure packages imported by server code are in dependencies, not devDependencies.
  • The build fails on an engine or syntax error: the base image's Node.js is too old. Use Node.js 22.23.4 or later.
  • A public variable is empty in the browser: it wasn't available during docker build. Pass it with --build-arg and an ENV line in the build stage.
  • Users get logged out after a deploy: sessions are on the container's filesystem. Use a shared session driver.

FAQ: Deploying Astro With Docker

Only if your site has on-demand pages, endpoints, actions, server islands or sessions. A fully static site can be built in one stage and served from an NGINX or Apache image with no Node.js at runtime.

It uses the PORT environment variable, and the Astro Docker examples set it to 4321. Set HOST to 0.0.0.0 as well, expose the port in the Dockerfile and map it with docker run -p.

The Node adapter doesn't load .env files at runtime, and a good .dockerignore keeps them out of the image anyway. Pass runtime values with docker run -e or --env-file, and build-time values with --build-arg.

Use npm ci when you have a package-lock.json. It installs exactly what the lockfile specifies and fails if the lockfile and package.json disagree, which makes image builds reproducible.

Yes, but Alpine uses musl rather than glibc, which can affect native packages such as sharp. If image optimisation fails on Alpine, switch to a slim Debian-based image.

Anywhere that runs containers, including a VPS with Docker or Docker Compose, Google Cloud Run, AWS, Azure and Fly.io. Push the image to a registry and follow your platform's instructions for running it.


Conclusion

Running Astro in Docker comes down to three things: the Node adapter in standalone mode, a multi-stage Dockerfile that builds with all dependencies and runs with only what's needed, and a server started with HOST=0.0.0.0 and a known PORT. Pin your Node.js base image to a version that meets Astro's requirement, add a .dockerignore, and run as the node user.

Most production issues come from state and configuration rather than Docker itself. Keep secrets out of the image and pass them at runtime, provide build-time public variables as build arguments, move sessions and uploads to shared storage, and add a health check endpoint. If your site is fully static, skip Node.js entirely and serve dist with NGINX.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper