Self-Hosting with Node.js
Deploy an Astro SSR site to your own server using PM2, CloudPanel, Nginx, and MariaDB.
Overview
This guide explains how to deploy an Astro SSR site to a VPS or dedicated server. We use the same stack as this showcase site:
| Layer | Tool |
|---|---|
| Web server / reverse proxy | Nginx (via CloudPanel) |
| Node.js process manager | PM2 |
| Database | MariaDB 11.4 |
| SSL certificates | Let’s Encrypt (via CloudPanel) |
Step 1 — Configure the Astro adapter
Install the Node.js adapter:
npm install @astrojs/node
Update astro.config.mjs:
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
})
mode: 'standalone' produces a self-contained dist/server/entry.mjs that you
run directly with Node — no separate Express server needed.
Step 2 — Build the project
npm run build
Output goes into dist/. The file you’ll run is dist/server/entry.mjs.
Step 3 — Set up PM2
PM2 is a process manager that keeps your Node server alive, restarts it after crashes, and auto-starts it after server reboots.
npm install -g pm2
Create ecosystem.config.cjs at the project root:
module.exports = {
apps: [{
name: 'my-astro-site',
script: './dist/server/entry.mjs',
env: {
HOST: '0.0.0.0',
PORT: 3001,
NODE_ENV: 'production',
},
max_memory_restart: '512M',
exp_backoff_restart_delay: 100,
}],
}
Start the app:
pm2 start ecosystem.config.cjs
pm2 save # save process list so it survives reboots
pm2 startup # print the command to enable auto-start, then run it
Step 4 — Configure Nginx reverse proxy
CloudPanel generates an Nginx vhost automatically. Edit it to proxy traffic to your Node server on port 3001:
server {
listen 443 ssl;
server_name my-site.com;
location / {
proxy_pass http://127.0.0.1:3001;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}
In CloudPanel: Sites → your-domain → Vhost — paste the location / block.
CSRF note: Astro 6’s built-in CSRF check compares the
Originheader to theHostheader. Behind Nginx these may differ. If you see"Cross-site POST form submissions are forbidden", add this toastro.config.mjs:security: { checkOrigin: false }All your form inputs should still be validated server-side.
Step 5 — Environment variables
Copy .env.example to .env and fill in your values on the server:
cp .env.example .env
nano .env
PM2 reads environment variables from ecosystem.config.cjs. You can also load
a .env file using dotenv at the top of your entry point, or set them in
ecosystem.config.cjs’s env block.
Step 6 — Enable SSL
In CloudPanel: Sites → my-site.com → SSL/TLS → Let’s Encrypt → Create Certificate.
CloudPanel handles renewal automatically.
Deploy script
Automate every deploy with a single script. Create deploy.sh:
#!/bin/bash
set -e
echo "→ Pulling latest code..."
git pull origin main
echo "→ Installing dependencies..."
npm install --production=false
echo "→ Building..."
npm run build
echo "→ Running migrations..."
npm run db:migrate
echo "→ Creating upload directories..."
mkdir -p uploads/screenshots
echo "→ Restarting server..."
pm2 restart my-astro-site
echo "✓ Deploy complete."
Make it executable: chmod +x deploy.sh. Every deploy is then just:
bash deploy.sh
Useful PM2 commands
pm2 status # see all running processes
pm2 logs my-astro-site # stream live server output
pm2 restart my-astro-site # restart without full redeploy
pm2 reload my-astro-site # zero-downtime reload
pm2 stop my-astro-site # stop the process
pm2 delete my-astro-site # remove from PM2 process list
Serving uploaded files
Files saved to disk (like screenshots) live outside dist/ so they survive rebuilds.
Create a dedicated API route to serve them safely:
// src/pages/s/[filename].ts
import type { APIRoute } from 'astro'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export const GET: APIRoute = async ({ params }) => {
// Validate filename — only allow safe characters to prevent path traversal
if (!/^[\w.-]+$/.test(params.filename ?? '')) {
return new Response('Not found', { status: 404 })
}
const filePath = path.join(process.cwd(), 'uploads', 'screenshots', params.filename!)
try {
const buffer = await readFile(filePath)
return new Response(buffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
} catch {
return new Response('Not found', { status: 404 })
}
}