How to Deploy Astro on a VPS: Static and SSR Modes with Node Adapter and Nginx
Astro is a content-focused web framework that builds fast websites through its “islands” architecture — components are rendered to static HTML at build time, with JavaScript hydration only for interactive elements. Deploying Astro on a VPS is flexible: purely static sites (no JavaScript on the server) need only Nginx; sites using server-side rendering with Astro’s Node adapter need Node.js and PM2. This guide covers both modes.
Astro Output Modes
output: 'static'(default): Builds to a directory of HTML/CSS/JS files. Nginx serves them directly — zero Node.js required at runtime. Maximum performance, zero server-side overhead.output: 'server': Full SSR — every page rendered on demand. Requires Node adapter and a running Node.js process.output: 'hybrid': Mixed — most pages pre-rendered to static, specific pages/endpoints SSR. Requires Node adapter.
Option A: Static Astro Build (Nginx-Only Deployment)
Build Locally
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'static', // default, can omit
build: {
assets: '_astro',
},
});
npm run build
# Creates dist/ directory with all static files
Deploy Static Files to VPS
rsync -avz --delete ./dist/ deploy@YOUR_VPS_IP:/var/www/myastrosite/
Nginx Configuration for Static Astro
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
root /var/www/myastrosite;
index index.html;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Astro's _astro/ directory contains versioned hashed assets — cache forever
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
gzip on;
gzip_types text/html text/css application/javascript application/json image/svg+xml;
location / {
try_files $uri $uri/ $uri.html =404;
}
}
sudo ln -s /etc/nginx/sites-available/myastrosite /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Option B: SSR Astro with Node Adapter
Configure for SSR
npm install @astrojs/node
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone', // Generates a self-contained server in dist/
}),
});
npm run build
# Creates dist/server/entry.mjs and dist/client/
PM2 Configuration for Astro SSR
// /var/www/myastrosite/ecosystem.config.cjs
module.exports = {
apps: [{
name: 'astro-ssr',
script: '/var/www/myastrosite/dist/server/entry.mjs',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
HOST: '127.0.0.1',
PORT: 4321,
},
max_memory_restart: '512M',
}],
};
pm2 start /var/www/myastrosite/ecosystem.config.cjs
pm2 save && pm2 startup
Nginx Reverse Proxy for Astro SSR
server {
listen 443 ssl http2;
server_name yourdomain.com;
# Serve static assets directly (faster than proxying)
location /_astro/ {
alias /var/www/myastrosite/dist/client/_astro/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SSR pages proxy to Node.js
location / {
proxy_pass http://127.0.0.1:4321;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
GitHub Actions Deployment Pipeline
# .github/workflows/deploy.yml
name: Deploy Astro
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci && npm run build
- name: Sync to VPS
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
rsync -avz --delete -e "ssh -o StrictHostKeyChecking=no" \
./dist/ deploy@${{ secrets.VPS_HOST }}:/var/www/myastrosite/
# For SSR mode only — reload PM2
- name: Reload PM2 (SSR)
if: false
run: |
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.VPS_HOST }} \
"pm2 reload astro-ssr --update-env"
Hybrid Mode: Best of Both Worlds
Astro’s hybrid mode pre-renders everything to static HTML except routes explicitly marked as SSR — ideal for blogs with a few dynamic pages (search, user accounts, API routes):
// astro.config.mjs
export default defineConfig({
output: 'hybrid',
adapter: node({ mode: 'standalone' }),
});
// src/pages/search.astro — This page is SSR only
---
export const prerender = false;
const query = Astro.url.searchParams.get('q');
const results = await searchDatabase(query);
---
<SearchResults results={results} />
Performance: Astro on VPS vs Vercel/Netlify
- Static mode: Nginx-served Astro is as fast or faster than Vercel/Netlify CDN for single-region audiences — TTFB under 50ms from NVMe-backed Nginx. Add Cloudflare for global CDN distribution.
- SSR mode: No cold starts (Node.js process always running), no function timeout limits, full WebSocket support.
- Cost: After Vercel/Netlify free tier limits, a $10/month VPS serves comparable traffic at lower cost.
Getting Started
Static Astro needs minimal VPS resources — any Ubuntu VPS at VPS.DO handles static Astro sites with Nginx effortlessly. For SSR mode, 1 GB RAM is the practical minimum; 2 GB is comfortable with room for growth. NVMe storage keeps Nginx’s static file serving fast and build artifact transfers quick.
Conclusion
Astro’s dual output modes make VPS deployment straightforward for any use case: static output requires only Nginx and delivers exceptional performance with zero server-side overhead; SSR/hybrid mode with the Node adapter and PM2 enables dynamic pages without cold starts or execution time limits. Both are self-hosted alternatives to Netlify and Vercel that reduce costs at meaningful traffic volumes while providing complete infrastructure control.