Astro is best known for shipping zero JavaScript by default, but it's equally capable as a server-rendered framework. Switch on SSR with an adapter and you can build dynamic, database-backed pages and API endpoints. This guide deploys an Astro SSR app with a managed PostgreSQL database.
Static vs SSR vs hybrid
Astro supports three output modes:
static— prerender everything at build time (the default; deploy as a static site).server— render on every request (full SSR; needs a server runtime).hybrid— prerender most pages but mark some as server-rendered withexport const prerender = false.
For database-backed dynamic pages you need server or hybrid. We'll use server with the Node adapter.
Configuring the Node adapter
Install and configure the adapter:
npx astro add nodeThis updates astro.config.mjs:
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});The standalone mode produces a self-contained Node server you run directly. (The alternative, middleware mode, expects you to wire it into an existing Express/Connect app.)
Building
npm run buildWith the standalone Node adapter, this emits a server entry at dist/server/entry.mjs. The server reads HOST and PORT from the environment:
HOST=0.0.0.0 PORT=4321 node ./dist/server/entry.mjsDatabase-backed pages
Link a managed PostgreSQL database so DATABASE_URL is injected, then query it directly in a server-rendered page or an API route. Astro pages run on the server in SSR mode:
---
// src/pages/products.astro
import postgres from 'postgres';
const sql = postgres(import.meta.env.DATABASE_URL);
const products = await sql`SELECT id, name, price FROM products`;
---
<ul>
{products.map((p) => <li>{p.name} — ${p.price}</li>)}
</ul>For a connection pool reused across requests, initialize the client in a shared module rather than per page:
// src/lib/db.ts
import postgres from 'postgres';
export const sql = postgres(import.meta.env.DATABASE_URL);API endpoints
Astro endpoints in src/pages/api/ give you JSON routes alongside your pages:
// src/pages/api/health.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = () => new Response('ok', { status: 200 });Point the platform's readiness probe at /api/health.
The Dockerfile
# ---- Build ----
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime ----
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]Unlike Nitro-based frameworks, the Astro Node adapter doesn't bundle node_modules, so we copy them into the runtime stage. If image size matters, prune dev dependencies with npm prune --omit=dev after the build.
Environment variables and secrets
| Variable | Purpose |
|---|---|
NODE_ENV | production |
HOST | 0.0.0.0 |
PORT | injected listen port |
DATABASE_URL | injected by managed DB link |
Astro exposes env vars to the client only when prefixed with PUBLIC_. Keep DATABASE_URL unprefixed so it stays server-side. Access server secrets via import.meta.env in .astro frontmatter or endpoints — they're stripped from the client bundle.
Hybrid: mixing static and dynamic
If most of your site is static (marketing pages) and only a few routes need the database, use output: 'server' plus export const prerender = true on the static pages. Astro prerenders those at build time and renders the rest on demand — the best of both worlds for cost and performance.
Deploying
Push, connect the repo, link a managed PostgreSQL database, set HOST and NODE_ENV, and deploy. The platform builds the Dockerfile (or auto-detects the framework) and runs the standalone Node server. Live build logs surface any SSR build issues; app logs confirm the server bound to the injected port.
git push origin mainConclusion
Astro SSR deploys as a standalone Node server once you add the Node adapter in standalone mode, build, and run the emitted entry.mjs binding to 0.0.0.0 and the injected port. Server-rendered pages and endpoints read DATABASE_URL directly, and hybrid mode lets you prerender the static majority for free.
Try an Astro SSR app plus a managed PostgreSQL on PandaStack's free tier — connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and the database auto-wires via DATABASE_URL.
References
- [Astro: On-demand Rendering (SSR)](https://docs.astro.build/en/guides/on-demand-rendering/)
- [Astro Node.js Adapter](https://docs.astro.build/en/guides/integrations-guide/node/)
- [Astro: Endpoints](https://docs.astro.build/en/guides/endpoints/)
- [Astro: Environment Variables](https://docs.astro.build/en/guides/environment-variables/)
- [postgres.js Client](https://github.com/porsager/postgres)