All posts
Astro, Nginx, and Node.js server rack with rocket launch and cloud infrastructure
  • Astro
  • Nginx
  • Deployment
  • Node.js
  • SSL
  • VPS

Deploying Astro on a VPS with Nginx (Node.js Adapter)

A step-by-step guide to deploying an Astro site on a VPS using the Node.js standalone adapter and Nginx as a reverse proxy, with PM2 process management and Let's Encrypt SSL.

4 min read

This guide covers deploying an Astro site on a VPS (Ubuntu 22.04 / 24.04) using the official @astrojs/node standalone adapter. Nginx acts as a reverse proxy in front of the Node.js process, which PM2 keeps alive across reboots.

Prerequisites

  • A VPS running Ubuntu 22.04 or 24.04
  • A domain name pointed at your server’s IP
  • Node.js 18.17 or later installed on the server
  • npm or pnpm available

1. Configure the Node.js Adapter

Install the adapter in your Astro project:

npm install @astrojs/node

Update astro.config.mjs to use it in standalone mode:

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

export default defineConfig({
  output: 'server',       // or 'hybrid' if only some pages are SSR
  adapter: node({
    mode: 'standalone',   // bundles a Node.js HTTP server with the build
  }),
});

Static-only sites: If your Astro project has no server-side routes, API endpoints, or SSR pages, you can use output: 'static' and skip the adapter entirely. In that case skip to the static section at the end of this guide.


2. Build Your Astro App

Build locally before transferring to the server:

# Install dependencies
npm install

# Build for production
npm run build

The build output has two directories:

  • dist/client/ — static assets (JS, CSS, images)
  • dist/server/ — Node.js server bundle and entry.mjs

3. Transfer Files to Your Server

# From your local machine
scp -r dist package.json package-lock.json username@your-server-ip:/var/www/your-astro-app

Create the target directory on the server first if it doesn’t exist:

sudo mkdir -p /var/www/your-astro-app
sudo chown -R $USER:$USER /var/www/your-astro-app

4. Install Production Dependencies on the Server

SSH into your server and install dependencies:

cd /var/www/your-astro-app
npm install --omit=dev

Test that the server starts correctly:

node dist/server/entry.mjs

You should see output like Server listening on http://0.0.0.0:4321. Stop it with Ctrl+C — PM2 will manage it going forward.


5. Configure Nginx as a Reverse Proxy

Install Nginx if you haven’t already:

sudo apt update
sudo apt install nginx

Create a site configuration:

sudo nano /etc/nginx/sites-available/astro

Add the following (replace yourdomain.com and the port if you changed it):

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # Gzip compression
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_proxied any;
    gzip_vary on;
    gzip_types
        application/javascript
        application/json
        application/x-javascript
        application/xml
        image/svg+xml
        text/css
        text/javascript
        text/plain
        text/xml;

    # Long-term caching for Astro's hashed static assets
    location /_astro/ {
        proxy_pass http://localhost:4321/_astro/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # Proxy everything else to the Node.js server
    location / {
        proxy_pass http://localhost: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-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Enable the site, test the config, and reload Nginx:

sudo ln -s /etc/nginx/sites-available/astro /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

6. Run the App with PM2

PM2 keeps the Node.js process alive and restarts it on server reboot. See the PM2 Node.js Process Management Guide for a full reference including cluster mode, log rotation, and ecosystem config files.

# Install PM2 globally
sudo npm install -g pm2

# Start the Astro server
cd /var/www/your-astro-app
pm2 start dist/server/entry.mjs --name "astro-app"

# Save the process list and set up startup script
pm2 startup
pm2 save

pm2 startup prints a command to run — copy and run it to register PM2 with systemd.

Useful PM2 commands:

pm2 status           # List all running processes
pm2 logs astro-app   # Stream logs
pm2 restart astro-app
pm2 stop astro-app

7. Set Up SSL with Let’s Encrypt

Install Certbot and obtain a certificate. For a deeper walkthrough of HSTS, auto-renewal configuration, and troubleshooting common Certbot errors, see Set Up HTTPS with Let’s Encrypt on Nginx and Apache.

sudo apt install certbot python3-certbot-nginx

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot modifies your Nginx config automatically to redirect HTTP to HTTPS and configure the certificate. Verify auto-renewal is active:

sudo systemctl status certbot.timer

Changing the Port

By default the Node.js adapter listens on port 4321. To change it, set the PORT and HOST environment variables before starting the process:

# Via PM2
pm2 start dist/server/entry.mjs --name "astro-app" -- --env PORT=8080,HOST=127.0.0.1

# Or via PM2 ecosystem config (see the PM2 guide for full details)

Update the proxy_pass port in your Nginx config to match.


Static-Only Deployment (No SSR)

If your Astro project uses output: 'static', the build produces only a dist/ directory of flat files. Skip the Node.js adapter entirely and serve the files directly from Nginx:

# Transfer just the dist directory
scp -r dist/* username@your-server-ip:/var/www/yourdomain.com/html/

Nginx config for static files:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    root /var/www/yourdomain.com/html;
    index index.html;

    gzip on;
    gzip_types text/css application/javascript image/svg+xml text/plain;

    location /_astro/ {
        expires 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location / {
        try_files $uri $uri.html $uri/ =404;
    }

    error_page 404 /404.html;
}

Then run Certbot as before. No PM2 needed.


Important Notes

  • Node.js version: The server must have Node.js 18.17 or later.
  • Environment variables: Set production secrets on the server, not in the repo. Export them before starting PM2, or use a .env file read by your app.
  • output: 'hybrid': Hybrid mode lets you mark individual pages as prerender = true while keeping others server-rendered. The adapter and Nginx config work identically to the full server mode.
  • File permissions: Ensure the user running PM2 can read /var/www/your-astro-app.
  • Firewall: Only ports 80 and 443 need to be open publicly. Port 4321 should not be exposed directly.

Frequently Asked Questions

What’s the difference between standalone and middleware adapter modes?

standalone mode bundles a complete Node.js HTTP server — you run node dist/server/entry.mjs directly. middleware mode exports an Express/Connect-compatible handler that you embed in your own custom server. For most VPS deployments, standalone is simpler.

Do I need to transfer node_modules to the server?

No. Transfer package.json, package-lock.json, and the dist/ directory, then run npm install --omit=dev on the server. This keeps the transfer size small and ensures native modules compile for the server’s architecture.

How do I update the deployment after a code change?

Build locally, transfer the new dist/ directory to the server, then restart the PM2 process:

pm2 restart astro-app

Can I run Astro and Next.js on the same server?

Yes. Give each app a different port (e.g. 4321 and 3000) and create a separate Nginx server block for each domain. PM2 manages both processes independently.

My API endpoints aren’t working. What should I check?

Confirm output is set to 'server' or 'hybrid' (not 'static'), the adapter is installed and referenced in astro.config.mjs, and the Nginx location / block is proxying to the correct port. Also check pm2 logs astro-app for runtime errors.