How to Deploy an Astro Site — Static and SSR From the Same Repo [Full Repo]

One repo, two builds: Astro's default static output and a server-rendered Node adapter build, both containerized. Includes the boot crash the standalone adapter hides, why the image gets big, and what the live HTML showed.

An Astro repo forking into its two build outputs — static HTML and a Node SSR server — with the devalue boot crash flagged as the trip-up and the live result showing no external script tags in the served HTML

Astro is two deployment stories wearing one framework. Leave the config alone and astro build writes plain HTML — host it anywhere, no Node in sight. Change the output mode and the same repo builds a Node process that renders on request. This guide containerizes both from one repository: the SSR variant we deployed and measured live, and the static variant with its own Dockerfile.

The SSR path is the one backed by our live evidence; the code for both modes is in the companion repo (astro 7.4.7, @astrojs/node 11.1.11).

Choose static or SSR first

Answer this before writing a Dockerfile, because the static answer makes half of this guide unnecessary.

Your Astro siteBuild outputDeployment shape
Content pages, maybe interactive islandsstatic dist/ of HTMLthe nginx variant below (or any static container)
Pages that must render per request (auth, personalization, live data)a Node server in dist/server/ (with an adapter, prerendered pages can coexist with it — the folder alone does not prove every page renders per request)the SSR container, this guide’s main path
Mostly static with a few server routesa hybridthe SSR container, with routes controlling their own prerendering

For everyone continuing, one economics note up front — the free plan we tested on gives an app a URL that expires 60 minutes after its creation; a redeploy inside that hour did not reset it. Fine for proving the pipeline; a paid plan is the hosting answer.

Configure the output mode

Here is the trap we hit first: npx astro add node -y edits astro.config.mjs for you, installs the adapter, and looks done — but output stays at its default, which is static. The build keeps prerendering everything, and your shiny Node server boots up to serve files any static host could have served.

The modes, per Astro’s configuration reference: the default prerenders every page at build time, and output: 'server' flips that default so pages render per request.

The split is also controllable per route — a page can opt out of its mode’s default with a prerender export, which is how hybrid sites keep most pages static (on-demand rendering guide). Our starter wants always-on SSR, so it sets the mode explicitly:

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

// This starter ships in SSR mode ("server" + the Node standalone adapter), so
// `astro build` produces a Node server in dist/server/entry.mjs that you can
// run in a container (it honors the HOST and PORT env vars).
//
// Astro's default is a fully static build: to go back to it, remove the
// `output` and `adapter` lines below — `astro build` then emits plain HTML
// into dist/ that any static file server (or nginx) can host.
export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone'
  })
});

With that in place, npm run build writes dist/, including dist/server/entry.mjs — the production server, started with node ./dist/server/entry.mjs and configured through the HOST and PORT environment variables.

Build the Docker image (SSR)

Our first runtime image copied dist/ and package.json into a clean node:22-alpine, on the reasonable theory that a mode named standalone stands alone. The container exited immediately:

ERR_MODULE_NOT_FOUND: Cannot find package 'devalue'

The built server keeps some of its imports external and expects to find them in node_modules. So the runtime stage must install production dependencies, and the Dockerfile that actually works looks like this:

# ---- Build stage ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- Runtime stage ----
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080

# Pitfall: @astrojs/node "standalone" mode is NOT fully self-contained —
# the built server still imports packages (e.g. devalue) from node_modules,
# so production dependencies must be installed in the runtime image.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=build /app/dist ./dist

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget -qO /dev/null http://127.0.0.1:8080/ || exit 1
USER node
CMD ["node", "./dist/server/entry.mjs"]

Understand the image size

Build that image and the number jumps out: 583 MB in our measurement, where comparable Node-server starters we measured the same week — same node:22-alpine base — landed around 240 MB.

What the image contains is three layers of weight: the Node base image, the installed production node_modules, and the build output (a small dist/; exact size in the measurements table).

The difference against those comparable starters comes from the middle layer: astro itself sits in dependencies here, and the devalue crash above forces npm ci --omit=dev into the runtime stage — so “install production deps” means “install the framework.” The size is this combination’s price, not an Astro constant, and not a split we can itemize beyond what the comparison shows.

Two honest levers exist. A fully static build needs no node_modules at runtime at all — the nginx variant below sidesteps the entire question.

And in SSR, only what the built server imports at runtime has to live in dependencies; auditing that list is reasoning we did not measure, so treat it as a direction, not a promised number. Prerendering some pages, by itself, does not remove any packages from the image.

Build the static variant (deployed live on November 9, 2026 (nginx served the build at its public URL with status 200))

The same repo serves the static story: remove output and adapter from astro.config.mjs, and astro build emits plain HTML into dist/. A container for that build needs no Node at runtime — nginx serves the folder:

# ---- Build stage ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- Serve stage ----
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
# nginx.conf
server {
    listen 8080;
    root /usr/share/nginx/html;
    index index.html;
}

To be explicit about evidence: we deployed and measured the SSR variant; this static container was not part of the live test. Validate it locally the same way you would any container — docker build -t astro-static ., docker run -p 8080:8080 astro-static, then confirm curl http://localhost:8080/ returns your page.

The platform steps below apply to it unchanged, since the port and source settings are the same.

Deploy on Back4app

The platform flow is short, and two of its screens are identical to every container deploy in this series.

  1. New Web Deployment → select the GitHub repository.

The Choose a source step of a new Web Deployment, with GitHub repository, Upload files and Docker Hub image as the available paths

Source selection, shared across this series’ deploys — dashboard as of November 2026.

  1. The Dockerfile is detected. One field is on you: Port. Enter 8080 — our deploys set it at creation, because the platform does not inherit the Dockerfile’s EXPOSE, and the React guide shows the exact failure that omission causes.

The Build & deploy settings page with Dockerfile selected as build method and the Port field set to 8080

Port 8080, typed in, not assumed. Dashboard as of November 2026.

  1. Create the app and wait for the first deploy; ours was dominated by the runtime-stage install, as expected from the local builds.

The "Your app is ready" dialog after a first deploy: the temporary HTTPS URL with a copy button and the 60-minute expiry countdown

The free-plan URL and its 60-minute countdown. Dashboard as of November 2026.

Verify the live deployment

Astro’s pitch makes two checkable promises, so on November 9, 2026 we read both off the live URL’s raw response.

Rendered content: the HTML document arrived carrying the page’s content, <h1> included — the server handed over a finished page, not an empty shell.

One precision our test cannot add: a minimal page renders identically whether it was built at deploy time or per request, so distinguishing build-time from per-request rendering would need a per-request value (a timestamp, a request echo) in the page, which this template does not include.

Minimal JavaScript: the served HTML contained zero external <script> tags. We did not separately audit inline scripts, so the verified claim is exactly that — no external scripts — rather than “no JavaScript of any kind.” Islands you add will bring exactly the scripts they need.

Run the SSR deploy from an AI assistant (MCP)

Back4app’s remote MCP server lets an AI assistant perform the SSR deploy for you. It changes who clicks, not how the site is built: the Dockerfile above still produces the image. Connection steps per client are on the official MCP page.

Our guide to deploying with MCP and an AI assistant covers connection, authorization and diagnosis in full.

We ran it on November 10, 2026 with Claude (custom connector, signed in) against the SSR branch of the starter: create_upload, the npx @back4app/cli upload command it returns, then create_web_app with port 8080. The app was created with builder: DOCKERFILE and was READY about 120 seconds later.

The public page returned the rendered heading and referenced no JavaScript files, matching the dashboard deploy.

One timing detail an assistant should handle: the URL started answering 200 about five seconds before get_deployment reported READY, and returned 404 before that. Wait for the status, not for the first 200.

The static variant was not deployed through MCP in this test. Ids and timings: MCP evidence file.

Update the deployed app

A release is a push plus one dashboard action: App actions → Deploy the latest commit rebuilds onto the same URL, which we confirmed on this platform. On paid plans a push can deploy itself; on the free plan the click is part of the routine.

Re-creating the app re-reads the branch too, but issues a new URL — a fallback, not a workflow. The deep-dive on this flow, including a caveat about changed branch settings, is in the React article.

Measurements

The live deploy ran the starter at commit 8f75e8d (SSR mode); local figures are from November 6, 2026, live figures from November 9, 2026.

Transfer sizes are separated from on-disk sizes, and the static variant appears in none of these rows — it was not measured. Method and raw output: the repo’s evidence files.

WhatValueWhere
External script tags in served HTML0live URL
HTML document on / (transfer)312 B gziplive URL
App creation → serving~2.5 min (runtime-stage npm ci dominated)deploy timeline
TTFB on / (3 illustrative requests)1.375 s · 1.174 s · 1.204 slive URL
npm ci / npm run build (SSR mode)1.1 s / 3.9–4.5 slocal
docker build (no cache)57.8 slocal
Image size (on disk)583 MB (explained above)local
Build output dist/ (on disk)528 KBlocal

Troubleshooting

Boot crash: ERR_MODULE_NOT_FOUND: Cannot find package 'devalue' — the runtime image lacks production dependencies. Add COPY package.json package-lock.json ./ and RUN npm ci --omit=dev to the runtime stage; standalone mode does not exempt you.

The server runs but every page is prerendered — output is still at its static default because astro add node never changed it. Set output: 'server' in astro.config.mjs and rebuild; the difference is visible in whether dist/server/ exists.

Launch rejected with Please mention a port in settings to expose — the Port field was left on auto, which ignores the Dockerfile’s EXPOSE. Set 8080 under Build & deploy and redeploy.

Limitations of this setup

What this flow does not give you, stated plainly.

  • Free-plan mechanics: a URL that lasts 60 minutes from the app’s creation and a manual action per release; permanence and push-triggered deploys live on paid plans.
  • Image weight is structural here: as long as the standalone server needs the framework installed at runtime, the SSR image stays heavy; the real escape hatch is the static mode, not a Dockerfile trick we can point to.
  • Static variant is platform-validated: we published its Dockerfile and local checks, but our live evidence covers the SSR container only.
  • Rendering granularity: this starter is all-SSR; per-route prerendering is documented Astro behavior we did not exercise, so follow the official guides when you mix modes.

Auth, storage and APIs are a next step rather than a limitation: SSR routes can implement them server-side, or a managed backend can carry them over REST and GraphQL — in which case your Astro build might get to stay static after all.

What’s next?

Decide your output mode and take the matching Dockerfile — both are above, and the companion repo ships ready for the SSR one with the static flip documented inline (Containers docs).

Tagsastrossrstaticnode-adapterdockernginxcontainersdeploy

Frequently asked questions

Can I deploy Astro as a static site without a Node server?

Yes — that is Astro's default. With no output or adapter set in astro.config.mjs, astro build emits plain HTML into dist/ that any static file server can host; no Node process runs in production. This guide includes an nginx Dockerfile for exactly that build, and only the SSR variant (output: 'server' plus the Node adapter) needs the Node server container.

Does astro add node enable server-side rendering?

Not by itself. In our testing on astro 7.4.7 with @astrojs/node 11.1.11, npx astro add node -y wrote the adapter into astro.config.mjs but left output at its static default — the build still prerendered every page and the Node server merely served files. Per-request rendering comes from output: 'server', or from individual routes opting out of prerendering; see Astro's configuration reference for the output modes.

Why does my Astro container crash with Cannot find package 'devalue'?

Because the Node adapter's standalone build is not fully self-contained. With only dist/ and package.json copied into the runtime image, the server crashed on boot in our testing with ERR_MODULE_NOT_FOUND: Cannot find package 'devalue' — the built server keeps some imports external. The fix is npm ci --omit=dev in the runtime stage, so production dependencies exist at startup.

Why is my Astro Docker image so big?

In our measured case it is the combination, not the framework alone: astro sits in dependencies, and since the standalone server needs production packages installed at runtime (see the devalue crash), npm ci --omit=dev brings the whole framework into the image on top of the Node base. A static-mode build avoids this entirely — it needs no node_modules at runtime. The exact sizes we measured are in the article's measurements table.

Does Astro really ship zero JavaScript?

What we verified on our live deployment of the minimal template: the served HTML contained zero external script tags. We did not separately audit inline scripts, so the precise claim is no external scripts, not no JavaScript of any kind. Interactive islands you add later bring their own scripts — this is the baseline, not a ceiling.

Can I test an Astro container deploy for free?

Yes — our deploy ran on the free plan, with the runtime stage's dependency install dominating the deploy time (figures in the measurements section). The plan constraint to weigh first: a free-plan app's URL lasts 60 minutes from its creation, and releases need a manual dashboard action. Paid plans keep the URL and can deploy on push.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Nov 9, 2026 — Local: Node 22 LTS, Docker CLI (Colima) on an Apple Silicon Mac · Live: Back4app Containers, free plan (0.25 vCPU / 256 MB), deployed from GitHub, measured November 9, 2026.

Changelog

  • Nov 10, 2026 — Added a section on running this deploy from an AI assistant through the Back4app MCP server, tested on this starter.
  • Nov 10, 2026 — Free-plan URL lifetime restated: 60 minutes from the app's creation, not per deploy. Added a link to the full MCP deploy walkthrough.

More from Back4app Engineering

Web Deployment · alternative

Top 10 Vue Alternatives in 2026 (Compared & Ranked)

Containers · tutorial

How to Deploy a React App: 3 Methods, Step by Step [Full Repo]

Containers · tutorial

How to Host a Static Website on Back4app Containers With Docker and nginx [Full Repo]