How to Self-Host a Node.js Site on Raspberry Pi with Caddy and PM2 (2025)
Prerequisites Checklist
Before you touch a single config file, confirm you have every item below. Missing even one will cause you to hit a wall mid-setup.
Hardware and OS Requirements
- [ ] Raspberry Pi 3B+, 4, or 5 (4GB RAM or more strongly recommended)
- [ ] Raspberry Pi OS Lite 64-bit (Bookworm, released late 2023, is the current stable target)
- [ ] SSH access enabled on the Pi (
sudo raspi-config→ Interface Options → SSH) - [ ] Router admin credentials and the ability to set DHCP reservations and port forwarding rules
Software You Need Installed Locally and on the Pi
- [ ] Node.js 18+ installed on the Pi (
node -vto confirm; use NodeSource if the apt version is old) - [ ] npm 9+ or pnpm 8+ on the Pi
- [ ] Git on the Pi (
sudo apt install git -y) - [ ] A GitHub account with your project already in a repository
- [ ]
curlanddiginstalled locally for DNS verification
Accounts and Services: GitHub and a Domain Registrar
- [ ] A registered domain name (Namecheap, Porkbun, Cloudflare Registrar — any will work)
- [ ] GitHub account with Actions enabled on the repository
- [ ] Optional but recommended: a free DuckDNS or Cloudflare account for dynamic DNS
Note: This guide covers dynamic/SSR Node.js apps built with Astro (
output: 'server'), SvelteKit, Next.js (standalone mode), and React served via Express or a Vite preview server. If your site is purely static HTML afternpm run build, you only need Caddy'sfile_server— you can skip PM2 entirely. But if you have server-side rendering, API routes, or middleware, keep reading.
Estimated time: 60–90 minutes for a first-time setup, 15 minutes for subsequent projects once the infrastructure is in place.
Step 1: Configure Port Forwarding on Your Router
Your Raspberry Pi lives behind your home router on a private LAN IP (like 192.168.1.42). When someone types your domain in a browser, the request hits your router's public IP on port 80 or 443. Without port forwarding, your router drops that request — it has no idea where to send it. Port forwarding tells the router: "traffic arriving on port 80 and 443 goes to the Pi."
Find Your Raspberry Pi's Local IP Address
SSH into your Pi and run:
hostname -I
# or for more detail:
ip addr show eth0
You'll see something like 192.168.1.42. That's your Pi's current local IP.
Set a Static Local IP for the Pi
If the Pi's local IP changes (DHCP lease renewal), your port forwarding rule breaks. Fix this two ways:
Option A — DHCP reservation in your router: Find the Pi's MAC address (ip link show eth0), then log into your router admin panel and create a DHCP reservation tying that MAC to 192.168.1.42 permanently. This is the cleanest approach.
Option B — Static IP in /etc/dhcpcd.conf (older Raspberry Pi OS versions):
sudo nano /etc/dhcpcd.conf
Add at the bottom:
interface eth0
static ip_address=192.168.1.42/24
static routers=192.168.1.1
static domain_name_servers=1.1.1.1 8.8.8.8
Replace the IP, gateway, and DNS with your actual network values, then sudo reboot.
Forward Ports 80 and 443 to the Pi
In your router's admin panel (usually at 192.168.1.1 or 192.168.0.1), find "Port Forwarding," "Virtual Servers," or "NAT" — the label varies by brand (TP-Link, ASUS, Netgear). Create two rules:
| Protocol | External Port | Internal IP | Internal Port | |----------|--------------|-----------------|---------------| | TCP | 80 | 192.168.1.42 | 80 | | TCP | 443 | 192.168.1.42 | 443 |
Warning: Some residential ISPs use Carrier-Grade NAT (CG-NAT), which means multiple customers share a single public IP. In this case, you physically cannot forward ports 80/443. Call your ISP and ask if you have a dedicated public IP, or request one. If CG-NAT is unavoidable, a lightweight VPS running as a WireGuard or SSH tunnel to your Pi is the fallback — but that's outside the scope of this guide.
Step 2: Configure DNS with Your Domain Registrar
Port forwarding gets traffic to your Pi once it arrives at your router. DNS is what gets traffic to your router in the first place — it maps your domain name to your router's public IP address.
Find Your Router's Public IP Address
curl -s https://api.ipify.org
That single command returns your current public IP. Write it down.
Add an A Record Pointing to Your Public IP
Log into your domain registrar's DNS management panel and create:
| Type | Name | Value | TTL | |------|---------------|------------------|-----| | A | @ | YOUR.PUBLIC.IP | 300 | | A | www | YOUR.PUBLIC.IP | 300 |
TTL of 300 seconds (5 minutes) is intentionally low — if your public IP changes, DNS updates propagate quickly instead of caching a stale IP for hours.
Handling Dynamic IP Addresses with DDNS
Most residential internet plans assign a dynamic public IP that can change when your router reboots or after a lease renewal. The solution is Dynamic DNS (DDNS).
DuckDNS (free): Sign up at duckdns.org, create a subdomain, and install their cron-based update script on the Pi. It pings their API every 5 minutes and updates the DNS record if your IP changed.
Cloudflare (free tier): Transfer your domain's nameservers to Cloudflare (or use Cloudflare Registrar), then use their API to update the A record automatically:
# Run this as a cron job on the Pi every 5 minutes
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/ZONE_ID/dns_records/RECORD_ID" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"yoursite.com\",\"content\":\"$(curl -s https://api.ipify.org)\",\"ttl\":300}"
After adding your DNS records, verify propagation:
dig yoursite.com A +short
# or
nslookup yoursite.com 8.8.8.8
Typically resolves within minutes with a 300s TTL, though some resolvers can take up to 48 hours to flush old caches.
Step 3: Install and Configure Caddy as a Reverse Proxy
Caddy is the right choice here for one killer reason: automatic TLS. It provisions a Let's Encrypt certificate for your domain, handles renewal, and serves HTTPS — all with zero additional configuration. With Nginx you'd need Certbot, cron jobs, and manual renewal hooks. Caddy does it out of the box.
Installing Caddy on Raspberry Pi OS
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy -y
Writing Your Caddyfile for Node.js App Proxying
Edit the main Caddyfile:
sudo nano /etc/caddy/Caddyfile
Here's a complete example hosting three different apps — Astro, SvelteKit, and a React/Next.js app — all on the same Pi:
# Astro site (SSR/standalone mode) on port 4321
astrosite.com, www.astrosite.com {
reverse_proxy localhost:4321
}
# SvelteKit site on port 5173
sveltesite.com, www.sveltesite.com {
reverse_proxy localhost:5173
}
# Next.js (standalone) or React/Express site on port 3000
nextsite.com, www.nextsite.com {
reverse_proxy localhost:3000
}
# If you also serve static files alongside the proxy (e.g., a public/ folder)
staticexample.com {
root * /home/pi/sites/mystaticsite/dist
file_server
}
Caddy automatically provisions TLS for every domain block listed. No tls directive needed — it's the default behavior when you use a real domain name.
Enabling Automatic HTTPS with Caddy
sudo systemctl enable --now caddy
sudo systemctl status caddy
Caddy must be able to reach Let's Encrypt on port 80 and 443 — this is why port forwarding from Step 1 must be working first. The first time Caddy starts with a real domain in the Caddyfile, it automatically performs the ACME challenge and stores the certificate in /var/lib/caddy/.local/share/caddy/.
Reloading Caddy After Config Changes
sudo caddy reload --config /etc/caddy/Caddyfile
# or via systemd:
sudo systemctl reload caddy
Do not use restart unless you need to — reload is a graceful config swap with zero downtime.
Step 4: Build and Serve Your Node.js App with PM2
Caddy handles incoming traffic and TLS. PM2 handles your Node.js process — keeping it alive, restarting it on crashes, and (crucially) relaunching it after a Pi reboot. Without PM2, your app dies the moment you close your SSH session.
Cloning Your Repository onto the Pi
cd ~/sites # or wherever you organize projects
git clone git@github.com:yourusername/your-repo.git
cd your-repo
Building the App with npm run build
npm ci # reproducible install from package-lock.json
npm run build # outputs to dist/ or .output/ depending on framework
For Astro specifically, you need the @astrojs/node adapter configured in astro.config.mjs or the build produces only static files with no server entry point:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server', // or 'hybrid' if you want some static + some SSR routes
adapter: node({
mode: 'standalone', // generates a self-contained entry.mjs you can run directly
}),
});
Install the adapter before building: npm install @astrojs/node
Locating the Entry Point File (entry.mjs / entry.cjs)
After npm run build, look inside the output directory:
ls dist/server/
# Astro standalone: entry.mjs
# SvelteKit: index.js (in build/)
# Next.js standalone: server.js (in .next/standalone/)
The file extension tells you the module format: .mjs is ESM, .cjs is CommonJS. PM2 handles both fine.
Starting the App with PM2 and Enabling Auto-Restart on Reboot
# Install PM2 globally
npm i -g pm2
# Start your app (Astro example)
cd ~/sites/your-repo
pm2 start dist/server/entry.mjs --name my-astro-site
# For Next.js standalone:
# pm2 start .next/standalone/server.js --name my-next-site
# For SvelteKit:
# pm2 start build/index.js --name my-svelte-site
# Save the current process list so PM2 restores it on reboot
pm2 save
# Generate and install the systemd startup hook
pm2 startup
The pm2 startup command prints a sudo env PATH=... command that you must copy and run manually. It looks like this:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u pi --hp /home/pi
Run that exact command (it will differ slightly based on your username and Node path). Without it, PM2 itself won't start on boot, and your site goes dark every time the Pi loses power.
Useful PM2 debugging commands:
pm2 status # overview of all processes
pm2 logs my-astro-site # live log stream
pm2 restart my-astro-site # manual restart
Step 5: Automate Deployments with GitHub Actions and SSH
Right now, deploying a new version of your site requires SSHing into the Pi, pulling, building, and restarting manually. GitHub Actions eliminates that entirely — every push to main triggers an automated deploy.
Creating the GitHub Actions Workflow File
In your repository, create the file .github/workflows/update_server.yml:
name: Deploy to Raspberry Pi
on:
push:
branches:
- main
jobs:
deploy:
name: SSH deploy
runs-on: ubuntu-latest
steps:
- name: Update server code and restart the app
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: pi
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
~/bin/pull-all.sh my-astro-site ~/sites/your-repo
Using SSH Key Pairs Instead of Passwords
Passwords in GitHub Secrets work, but SSH key pairs are more secure. On your local machine:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_pi
Copy the public key to the Pi:
ssh-copy-id -i ~/.ssh/github_actions_pi.pub pi@192.168.1.42
Then add the private key (~/.ssh/github_actions_pi) as a GitHub secret named SSH_PRIVATE_KEY. Use the key: parameter in appleboy/ssh-action instead of password:.
Writing the pull-all.sh Deployment Script on the Pi
Create ~/bin/pull-all.sh on the Pi:
mkdir -p ~/bin
nano ~/bin/pull-all.sh
chmod +x ~/bin/pull-all.sh
#!/usr/bin/env bash
set -euo pipefail
# Usage: pull-all.sh <pm2-process-name> <repo-directory>
PROCESS_NAME="${1:-my-astro-site}"
REPO_DIR="${2:-$HOME/sites/your-repo}"
echo "[deploy] Pulling latest code into $REPO_DIR"
cd "$REPO_DIR"
git pull origin main
echo "[deploy] Installing dependencies"
npm ci --prefer-offline
echo "[deploy] Building"
npm run build
echo "[deploy] Restarting PM2 process: $PROCESS_NAME"
pm2 restart "$PROCESS_NAME"
# Persist the updated process list so reboot survives the new state
pm2 save
echo "[deploy] Done."
The set -euo pipefail at the top is critical — it makes the script abort immediately if any command fails, so a broken build never triggers a restart with bad code.
Storing Secrets Securely in GitHub Repository Settings
Navigate to your GitHub repo → Settings → Secrets and variables → Actions → New repository secret. Add:
| Secret Name | Value |
|-------------------|------------------------------------|
| HOST | Your Pi's public IP or domain name |
| SSH_PRIVATE_KEY | Contents of your ~/.ssh/github_actions_pi private key file |
Note: If your Pi is behind CG-NAT or a dynamic IP, use your domain name (with DDNS keeping it updated) as the
HOSTvalue rather than a raw IP address.
Common Issues and Fixes
| Issue | Likely Cause | Fix |
|-------|-------------|-----|
| Site unreachable after full setup | ufw firewall on the Pi blocking ports 80/443 | sudo ufw allow 80 && sudo ufw allow 443 && sudo ufw reload |
| PM2 app not running after Pi reboot | pm2 startup command was never executed | Run pm2 startup, copy the printed sudo env PATH=... command, run it, then pm2 save |
| Caddy returns 502 Bad Gateway | App not running or running on wrong port | Check pm2 status, then curl http://localhost:4321 — if connection refused, the app crashed |
| GitHub Actions SSH step fails with "Host key verification failed" | GitHub runner doesn't have the Pi's host fingerprint | Add host_key_checking: false to the appleboy/ssh-action with: block |
| Astro build produces only static files, no entry.mjs | Missing @astrojs/node adapter or wrong output mode | Install @astrojs/node, set output: 'server' and mode: 'standalone' in astro.config.mjs |
Fix: Site Not Reachable After Setup (Firewall / Port Check)
Raspberry Pi OS Lite doesn't enable ufw by default, but if you or a script enabled it, ports 80 and 443 may be blocked:
sudo ufw status
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
Also verify Caddy is actually listening:
sudo ss -tlnp | grep -E '80|443'
Fix: PM2 App Crashes on Reboot (Missing pm2 startup Command)
This is the most common gotcha. Confirm by checking if PM2 is even running after a reboot:
systemctl status pm2-pi # replace 'pi' with your username
If it's inactive, re-run the full sequence:
pm2 startup
# Copy and run the printed sudo command
pm2 save
Fix: Caddy Returns 502 Bad Gateway (App Not Running on Expected Port)
A 502 means Caddy reached the Pi but got no response from your app on the configured port. Debug:
pm2 status
curl -v http://localhost:4321 # or 5173 / 3000 depending on your app
pm2 logs my-astro-site --lines 50
Common root cause: the app is trying to bind to port 4321 but the HOST environment variable is set to 127.0.0.1 (fine) or the app crashed on startup due to a missing environment variable. Check your logs.
Fix: GitHub Actions SSH Step Fails (Host Key Verification Failed)
The GitHub Actions runner is a fresh Ubuntu VM that has never seen your Pi before. Add this to your workflow's with: block:
with:
host: ${{ secrets.HOST }}
username: pi
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
host_key_checking: false # add this line
script: |
~/bin/pull-all.sh my-astro-site ~/sites/your-repo
Alternatively, pre-populate the runner's known_hosts with your Pi's fingerprint by adding a prior step that runs ssh-keyscan.
FAQ
Q: Do I need a static public IP address from my ISP?
No. A static public IP is convenient but not required. Use Dynamic DNS to solve this: sign up for DuckDNS (completely free), get a subdomain like yoursite.duckdns.org, and install their update script on the Pi as a cron job. It updates the DNS record every 5 minutes if your IP changes. If you own a custom domain, you can CNAME it to your DuckDNS subdomain, or use Cloudflare's API to update your A record automatically. Either approach gives you effectively static hostname resolution without paying for a static IP.
Q: Can I host multiple websites on one Raspberry Pi?
Yes — and it works well. Add a separate site block in your Caddyfile for each domain. Run each Node.js app on a different port with PM2 (e.g., site A on 4321, site B on 3000, site C on 5173). Point separate A records at the same public IP, and Caddy uses SNI (Server Name Indication) to route HTTPS traffic to the correct backend. You can realistically run 3–5 small to medium SSR sites on a Pi 4 with 4GB RAM without any performance issues, assuming traffic is modest.
Q: Is a Raspberry Pi powerful enough for real traffic?
Honest answer: it depends. A Pi 4 with 4GB RAM handles dozens of concurrent users fine for a personal blog, portfolio site, or small SaaS app with light traffic. The real bottleneck is usually the SD card — its random I/O is poor under load. Booting from a USB3-connected SSD dramatically improves reliability and response times. For a site expecting hundreds of simultaneous users, look at Vercel, Render, or a small DigitalOcean Droplet instead. But for personal projects, side projects, and low-traffic apps, a Pi is surprisingly capable and costs essentially nothing to run (2–5W power draw).
Recommended Tools
- DigitalOceanCloud hosting built for developers — $200 free credit for new users
- GitHubWhere the world builds software
- CloudflareFast, secure CDN and DNS for any website