AstroShowcase
Docs / Deployment / Self-Hosting with Node.js
Deployment Updated May 7, 2026

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:

LayerTool
Web server / reverse proxyNginx (via CloudPanel)
Node.js process managerPM2
DatabaseMariaDB 11.4
SSL certificatesLet’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 Origin header to the Host header. Behind Nginx these may differ. If you see "Cross-site POST form submissions are forbidden", add this to astro.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 })
  }
}