How to deploy an Astro site without Docker
Astro builds two completely different things depending on one line in your config. Without an adapter, it produces a folder of HTML files that any static host can serve. With the Node adapter, it produces a JavaScript server you have to run. Both are called an Astro build, both live in dist, and the deployment story for each has almost nothing in common with the other.
That is the source of nearly every confusing Astro deployment thread. So step one is not deploying — it is finding out which of the two you have.
Step 1: find out what your build produces
npm ci
npm run build
ls dist/
# STATIC (no adapter, or output: 'static'):
# dist/index.html
# dist/about/index.html
# dist/_astro/index.a1b2c3.css
# -> a folder of files. Nothing to run.
# SERVER (output: 'server' or 'hybrid' with @astrojs/node):
# dist/server/entry.mjs
# dist/client/_astro/...
# -> entry.mjs is a Node server you have to start.Or read it directly out of the config, which is faster:
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
// No output line at all, or 'static' -> static folder.
// 'server' -> everything rendered per request.
// 'hybrid' (or per-page prerender) -> mostly static, some routes dynamic.
output: "server",
// An adapter is the tell. If this line exists, you are shipping a server.
adapter: node({ mode: "standalone" }),
});Step 2a: deploying a static Astro site
This is the easy case, and it is genuinely as simple as it sounds. Astro is a recognised framework, so the install, build, and static serve are all inferred.
pandastack app create \
--name docs-site \
--git-url https://github.com/acme/docs-site \
--git-branch main
pandastack app deploy <app-id> --followA purely static build has no server process at all: the files are served from object storage behind the CDN. There is no cold start, because there is nothing to start, and nothing to pay for while nobody is visiting.
Step 2b: deploying an SSR Astro site
With the Node adapter in standalone mode, the build produces a server that reads HOST and PORT from the environment. You give it a start command; the rest is inferred.
pandastack app create \
--name shop \
--git-url https://github.com/acme/shop \
--start-command "node ./dist/server/entry.mjs"
# The standalone Node adapter reads HOST and PORT from the environment,
# so binding correctly is handled for you — but verify it in the logs on
# the first deploy rather than assuming.One detail that catches people: the adapter must be in dependencies, not devDependencies. A production install skips devDependencies, the build succeeds because the adapter was present at build time on your laptop, and the deploy fails at start with a module-not-found for something you can plainly see in your package.json.
{
"dependencies": {
"astro": "^5.1.1",
"@astrojs/node": "^9.0.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"typescript": "^5.7.2"
}
}Step 3: environment variables, which behave differently per mode
Astro follows Vite's convention: variables prefixed PUBLIC_ are inlined into client-side JavaScript, and everything else stays server-side. What that means for you depends entirely on which build you are shipping.
- In a static build, every value is resolved at build time. There is no server left to read anything at runtime, so a secret used during the build to fetch content is fine — it is not in the output — but there is no way to change a value without rebuilding.
- In an SSR build, non-public variables are read at runtime by the server process, so rotating a key means restarting rather than rebuilding.
- PUBLIC_ variables are always public, in both modes. They are string-substituted into files the browser downloads. Nothing with authority belongs there.
pandastack app create \
--name shop \
--git-url https://github.com/acme/shop \
--start-command "node ./dist/server/entry.mjs" \
--env PUBLIC_SITE_URL=https://shop.acme.com,STRIPE_SECRET_KEY=sk_live_...
# PUBLIC_SITE_URL -> ends up in the browser bundle. Intended.
# STRIPE_SECRET_KEY -> server-side only, in SSR mode, read via
# import.meta.env or process.env at runtime.
# In a STATIC build there is no runtime, so a key like this would only
# ever be usable during the build. If you need it per request, you need SSR.Step 4: content collections and the build-time data trap
Astro's content collections read Markdown and MDX from your repository at build time, which is fast and cheap and has one consequence people find surprising: publishing new content requires a new build.
- Content in the repo — a git push triggers a deploy and the content ships with it. Simple, versioned, and reviewable. This is Astro's happy path.
- Content from a CMS, built statically — your build fetches from the CMS, so new content needs a rebuild. Wire the CMS's publish webhook to trigger a deploy, or your editors will publish into the void and file a bug.
- Content from a CMS, rendered per request — SSR mode, fetched at request time. Content is live, and you have taken on a runtime dependency: when the CMS is down, your pages are down. Cache accordingly.
The pre-launch checklist
- The site option in astro.config points at the production URL. Check the generated sitemap for localhost before you announce anything.
- The adapter, if you have one, is in dependencies rather than devDependencies.
- Deep links work. For a static build, confirm that /about/ resolves — trailing-slash behaviour differs between hosts and Astro has a config option for it.
- Grep the built client output for anything secret. Any PUBLIC_ variable is in there in plain text.
- If the build calls an API, confirm what happens when that API is slow. A build with no timeout can hang until the deploy budget runs out.
The short version
Run the build and look at dist. A folder of HTML means you have a static site: deploy it, serve it from a CDN, and enjoy having no server to operate. A server/entry.mjs means you have an SSR app: give it a start command, put the adapter in dependencies, and treat it like any other Node service.
The mistake worth avoiding is reaching for SSR because it sounds more capable. Most Astro sites are content sites, and content that is identical for every visitor should be built once. Static is not the lesser option here — it is the one Astro is best at.
Frequently asked questions
Do I need an adapter to deploy an Astro site?
Only if you need server-side rendering. Without an adapter, Astro produces a folder of static HTML, CSS, and JavaScript that any static host or CDN can serve, and that is the correct setup for a documentation site, a blog, a marketing site, or anything where every visitor sees the same page. You need an adapter — @astrojs/node for a generic host, or a platform-specific one — when pages must be rendered per request: authenticated content, form handling, personalisation, or API routes. Astro also supports a hybrid approach where most pages are prerendered and specific routes opt into server rendering, which is often the right answer for a mostly-static site with one or two dynamic pages. Start static and add the adapter when a page genuinely needs the request.
Why does my Astro deployment fail with 'Cannot find module @astrojs/node'?
Because the adapter is in devDependencies and the production install skipped it. This is confusing precisely because the build succeeds: on your machine, and often in CI, all dependencies are installed, so the adapter is available when Astro needs it at build time. Then the deployment installs only production dependencies, the server tries to start, and the module is missing. Move @astrojs/node into dependencies and redeploy. The same trap catches any package your built output requires at runtime rather than only at build time, so it is worth a quick audit of your devDependencies for anything that ends up imported by dist/server/entry.mjs.
Should my Astro site be static or SSR?
Static unless a specific page genuinely depends on the request. Static builds have no server to run, no cold start, no runtime to patch, and cost essentially nothing to serve from a CDN, which is why Astro defaults to it. Choose SSR when you need per-request behaviour: showing a logged-in user their own data, handling form submissions, personalising content, or exposing API routes. If only a few routes need this, hybrid rendering lets you prerender everything else and opt those routes in individually, which keeps the fast, cheap path for the majority of your traffic. The failure mode to avoid is picking SSR for the whole site because it feels more flexible, and then paying for a server process to render identical HTML for every visitor.
How do I publish new content to a static Astro site?
It depends where the content lives. With content collections reading Markdown from your repository, publishing is a git commit — push it and the deploy pipeline rebuilds and ships. That is the simplest and most robust arrangement, and it gives you version history and review for free. If your content comes from a headless CMS and you build statically, the build fetches it, so a new article does not appear until a new build runs; wire the CMS's publish webhook to trigger a deploy, otherwise editors will publish and see nothing change. The third option is SSR with content fetched per request, which makes publishing instant at the cost of a live dependency on the CMS during every page view, and that needs caching to be sensible.
Can I deploy Astro without a Dockerfile?
Yes, and for most Astro sites a container adds nothing. Git-driven platforms detect Astro from your package.json, run the install and build, and either serve the static output directly or start the Node server for an SSR build. The runtime version comes from a .nvmrc or equivalent file in your repository, so you get the Node version you develop against without writing a container definition. A Dockerfile earns its place when the build needs system-level dependencies that the standard runtime does not provide — an image-processing library, a specific binary toolchain — or when you already maintain one for other services and consistency is worth more than convenience.
Keep reading
- The best Astro hosting platforms in 2026
- How to deploy a SvelteKit app without Docker
- Build-time vs runtime environment variables
- Scale-to-zero app hosting, explained
- PandaStack Apps — framework detection, no Dockerfile required
49ms p50 cold start. Fork, snapshot, and scale to zero.