Back to Blog
Tutorial6 min read2026-07-10

How to Deploy Astro: Static, SSR, and a Postgres Database

Deploy Astro the right way: static build to dist/, SSR with the Node adapter, PUBLIC_ env var rules, and a managed Postgres wired in via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Astro is unusual among modern frameworks because it has two genuinely different deployment shapes. By default it compiles to plain static files — HTML, CSS, and only the JavaScript your islands actually need. Add an adapter and it becomes a Node server that renders pages on demand. The mistake I see most often is people deciding this *after* they've built half the app. Decide first, because everything downstream — build output, start command, hosting type — depends on it.

Static or server? Decide before you deploy

Ask one question: does any page need data that changes per-request? Auth, personalized content, form handling with server logic, data that must be fresh on every load.

  • No → stay static. astro build gives you a dist/ folder of files. Cheapest and fastest option, nothing to keep running.
  • Yes → add the Node adapter and run Astro as a server. You get API routes, server-rendered pages, and access to a real database connection.

You can also mix: keep output static and opt individual pages out of prerendering with export const prerender = false once an adapter is installed. But for this tutorial I'll treat the two shapes separately, because they deploy differently.

Deploying static Astro

The production build is one command:

npm run build

which runs astro build and writes everything to dist/. Verify locally with:

npm run preview

On PandaStack, connect the Git repo as a static site. The framework is auto-detected — Astro is one of the frameworks the build pipeline is verified against — so the build command (astro build) and output directory (dist) are picked up without configuration. If you use pnpm or bun instead of npm, the install command is overridable. Push to your branch, watch the live build logs, and the site is on a URL with SSL. That's genuinely the whole flow for a content site.

One static-specific gotcha: if your site lives at a subpath or you generate absolute URLs (sitemaps, canonical tags, OG images), set site in astro.config.mjs:

import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://www.example.com',
});

Astro's sitemap and RSS integrations refuse to work sensibly without it, and it's the kind of thing you only notice after Google has indexed the wrong URLs.

Deploying Astro SSR with the Node adapter

For server rendering, add the official Node adapter:

npx astro add node

This installs @astrojs/node and updates your config. Set the adapter to standalone mode so the build output is a complete server, not middleware you have to mount yourself:

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

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

Now astro build produces two things inside dist/: a client/ folder of static assets and a server/ folder with the entry point. The production start command is:

node ./dist/server/entry.mjs

The standalone server reads HOST and PORT from the environment. Two things matter on any container platform:

  1. 1Bind to 0.0.0.0. Set HOST=0.0.0.0 or the platform's router can't reach the process.
  2. 2Respect the injected PORT. Don't hardcode 4321 (Astro's default) anywhere in your own code.

On PandaStack you'd deploy this as a container app rather than a static site. The buildpack detects Node, installs dependencies, runs the build, and starts the server. If you prefer explicit control, a Dockerfile works too:

FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "./dist/server/entry.mjs"]

Note that SSR output still needs node_modules at runtime — Astro's server bundle imports its dependencies rather than inlining everything, so don't ship only dist/.

Environment variables: the PUBLIC_ rule

Astro has a hard split that trips people up exactly once:

  • Variables prefixed PUBLIC_ are inlined into client-side code at build time via import.meta.env.PUBLIC_WHATEVER.
  • Everything else is server-only. In SSR code you can read it at runtime.

Two consequences. First, never put a secret in a PUBLIC_ variable — it ships to every browser. Second, because PUBLIC_ values are baked in at build time, changing one requires a rebuild, not a restart. If you change PUBLIC_API_URL in your dashboard and wonder why nothing happened, that's why — trigger a new deploy.

Server-side secrets like a database URL should be read only in .astro frontmatter, API routes, or server utilities — never in a component that hydrates on the client.

Wiring up a managed Postgres

An SSR Astro app plus a database covers a surprising amount of ground — comments, auth, dashboards — without reaching for a separate backend. On PandaStack, provision a managed PostgreSQL (14.x and 16.x are what runs in production there) and attach it to the app. DATABASE_URL is injected into the app's environment automatically, so there's no copying credentials between dashboards.

A minimal query layer using the postgres driver:

// src/lib/db.ts
import postgres from 'postgres';

export const sql = postgres(process.env.DATABASE_URL!, {
  max: 5, // keep the pool small; see below
});

And an API route that uses it:

// src/pages/api/posts.ts
import type { APIRoute } from 'astro';
import { sql } from '../../lib/db';

export const GET: APIRoute = async () => {
  const posts = await sql`select id, title from posts order by id desc limit 20`;
  return new Response(JSON.stringify(posts), {
    headers: { 'Content-Type': 'application/json' },
  });
};

Keep the pool small. Managed plans have real connection limits (50 on PandaStack's free tier, 300 on Pro), and every app instance holds its own pool. Five connections per instance is plenty for most Astro apps.

Migrations

Astro has no built-in migration story, which is fine — use your ORM's tooling. With Drizzle, for example, you'd generate SQL migrations locally and apply them with its migrate command against DATABASE_URL as a step before the new version takes traffic. Whatever tool you pick, the rule is the same as any platform: run migrations as a deliberate forward-only step, not in the app's startup path, so two instances rolling out at once can't race each other.

Going live via Git push

The end-to-end on PandaStack looks like this:

  1. 1Push the repo to GitHub and connect it — as a static site for output: 'static', or a container app for SSR.
  2. 2For SSR, attach a managed Postgres; DATABASE_URL shows up in the environment automatically.
  3. 3Set any PUBLIC_ variables and server secrets in the app's environment settings.
  4. 4git push. The build runs (rootless BuildKit in an ephemeral pod for containers), logs stream live, and the app goes live with SSL. Subsequent pushes redeploy, and deployment history gives you rollbacks when a release goes sideways.

One honest note if you're on the free tier: idle apps scale to zero, so the first request after a quiet period pays a cold start. For a portfolio or side project that trade-off is usually fine; for latency-sensitive production traffic, run a paid tier where instances stay warm.

Astro rewards this setup well — static where you can, server where you must, and a database that's just there when the app boots. If you want to see the git-push flow end to end, it takes a few minutes to try on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also