
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 thannode:latest, so a new major release doesn't change your runtime unexpectedly. -slimimages are Debian-based and small. They're a safe default.-alpineimages are smaller still but use musl instead of glibc, which occasionally causes problems with native modules such assharp. 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:
basecopies onlypackage.jsonandpackage-lock.json. Because the source code isn't copied yet, Docker can cache the dependency layers and skip reinstalling when only your code changes.prod-depsinstalls runtime dependencies only.build-depsinstalls everything, including dev dependencies needed for the build, such as TypeScript or@astrojs/check.buildcopies the source and runsastro build.runtimestarts from the small base again and copies in just the productionnode_modulesand thedistfolder.
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.envis inlined into the built code whennpm run buildruns. In Astro 6 and later it's always inlined and never coerced. That includes everyPUBLIC_variable used in client code. These values must exist duringdocker build. - Runtime:
process.envin on-demand routes and secrets read throughastro:envare read when the server runs. These should be passed todocker 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.0is set. If the server listens only onlocalhostinside 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, notdevDependencies. - 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-argand anENVline 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.


