Remote Dev Server Guide Part 5: Production Deployment with GitHub Actions & PM2
In Part 4, we set up Claude Code and our development workflow. Now let's close the loop: when you push to main, your app automatically builds, deploys to the server, and restarts — all through Tailscale's secure network.
Architecture Overview
flowchart LR
A["git push\nto main"] --> B["GitHub\nRepository"]
B --> C["GitHub Actions\nRunner"]
C -->|"1. Build (bun)\n2. Join Tailscale\n3. rsync to server\n4. Restart PM2"| D["Hetzner Server"]
D --- E["Nginx (443)\n↓ reverse proxy\nPM2 → Node.js (3003)"]
The critical part: the GitHub Actions runner joins your Tailscale network to SSH into the server. No SSH port is exposed to the public internet. The CI runner authenticates through Tailscale, just like your personal devices.
Step 1: Configure Nginx
Create a site configuration for your app:
sudo tee /etc/nginx/sites-available/myapp.com << 'EOF'
server {
server_name myapp.com www.myapp.com;
location / {
proxy_pass http://127.0.0.1:3003;
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;
}
listen 80;
}
EOF
sudo ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Key details:
proxy_passpoints to127.0.0.1:3003— the app only listens locallyUpgradeheaders support WebSocket connections (needed for Next.js HMR if you ever run dev mode)X-Forwarded-Protoensures your app knows it's behind HTTPS
Step 2: Set Up SSL with Let's Encrypt
Install Certbot and get a free SSL certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.com -d www.myapp.com
Certbot automatically:
- Obtains the certificate
- Modifies your Nginx config to use HTTPS
- Sets up HTTP → HTTPS redirect
- Configures automatic renewal via systemd timer
Verify auto-renewal works:
sudo certbot renew --dry-run
Step 3: Configure PM2
PM2 is a production process manager for Node.js. It handles restarts, log management, and process monitoring.
Since we installed Node.js system-wide via NodeSource in Part 2, the deploy user already has access to it. Just install PM2:
sudo npm install -g pm2
Create an ecosystem configuration at /var/www/ecosystem.config.js:
module.exports = {
apps: [
{
name: "myapp.com",
script: "server.js",
cwd: "/var/www/myapp.com",
env: { PORT: 3003, HOSTNAME: "127.0.0.1" },
},
// Add more apps as needed
{
name: "other-app",
script: "server.js",
cwd: "/var/www/other-app",
env: { PORT: 3004, HOSTNAME: "127.0.0.1" },
},
],
};
Every app binds to 127.0.0.1 — Nginx handles public-facing traffic.
Start your apps:
sudo -u deploy pm2 start /var/www/ecosystem.config.js
sudo -u deploy pm2 save
sudo -u deploy pm2 startup
pm2 save persists the process list, and pm2 startup creates a systemd service so PM2 starts automatically on server reboot.
Step 4: Next.js Standalone Build
For deployment, Next.js should build in standalone mode. This bundles only the necessary dependencies, resulting in a ~50 MB deployment instead of a 500+ MB node_modules folder.
In your next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
The standalone build outputs to .next/standalone/ with its own server.js entry point. You need to copy static files separately:
bun run build
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
Step 5: Generate Deploy Keys
Before setting up CI/CD, create an SSH key pair specifically for deployments:
# On your local machine
ssh-keygen -t ed25519 -f deploy_key -C "github-actions-deploy"
# Add the public key to the deploy user's authorized_keys on the server
# (the "hetzner" alias already includes port 1993 from your SSH config)
ssh hetzner "sudo -u deploy mkdir -p /home/deploy/.ssh && \
sudo -u deploy tee -a /home/deploy/.ssh/authorized_keys" < deploy_key.pub
# You'll add the private key to GitHub Secrets in the next step
cat deploy_key
Step 6: Set Up Tailscale for GitHub Actions
This is the secret sauce. Instead of exposing SSH to the internet for CI/CD, we make the GitHub Actions runner join your Tailscale network.
Create a Tailscale OAuth Client
- Go to Tailscale Admin Console → Settings → OAuth Clients
- Create a new OAuth client with the
Devices: Writescope - Save the Client ID and Client Secret
Create an ACL Tag
In your Tailscale ACL policy, add a tag for CI runners:
{
"tagOwners": {
"tag:ci": ["autogroup:admin"]
},
"acls": [
{
"action": "accept",
"src": ["tag:ci"],
"dst": ["tag:server:*"]
}
]
}
Add GitHub Secrets
In your GitHub repository, go to Settings → Secrets and add:
| Secret | Value |
|---|---|
TS_OAUTH_CLIENT_ID | Your Tailscale OAuth client ID |
TS_OAUTH_SECRET | Your Tailscale OAuth secret |
DEPLOY_KEY | Private key from Step 5 |
DEPLOY_HOST | Your server's Tailscale IP |
DEPLOY_USER | deploy |
DEPLOY_PATH | /var/www/myapp.com |
Step 7: Create the GitHub Actions Workflow
Create .github/workflows/deploy.yml:
name: Deploy to Hetzner
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
- name: Prepare standalone output
run: |
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
- name: Setup Tailscale
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p 1993 -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Deploy standalone to server
run: |
rsync -avz --delete --exclude='.env' \
-e "ssh -i ~/.ssh/deploy_key -p 1993" \
.next/standalone/ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/
- name: Restart PM2
run: |
ssh -i ~/.ssh/deploy_key -p 1993 ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "\
cd ${{ secrets.DEPLOY_PATH }} && \
pm2 delete myapp.com 2>/dev/null || true && \
PORT=3003 HOSTNAME=127.0.0.1 pm2 start server.js --name myapp.com"
Let's walk through each step:
- Build — Bun installs dependencies and builds the Next.js app in standalone mode
- Prepare — copies static assets into the standalone output
- Tailscale — the CI runner joins your Tailscale network using OAuth, tagged as
tag:ci - SSH — sets up SSH credentials to connect to your server's Tailscale IP on port 1993
- Deploy — rsync copies the standalone build to
/var/www/myapp.com/, deleting old files (but preserving.env) - Restart — kills the old PM2 process and starts a new one with the updated code
The entire deploy takes about 60-90 seconds for a typical Next.js app.
Environment Variables
Production apps often need API keys, database URLs, and other secrets. PM2 supports environment variables in the ecosystem config, but for sensitive values, use a .env file on the server:
# Create an env file for your app (as deploy user)
sudo -u deploy tee /var/www/myapp.com/.env << 'EOF'
DATABASE_URL=postgresql://user:[email protected]:5432/myapp
API_SECRET=your-secret-here
EOF
sudo -u deploy chmod 600 /var/www/myapp.com/.env
Next.js in standalone mode automatically loads .env files. For other frameworks, you can load them in the PM2 ecosystem config:
module.exports = {
apps: [{
name: "myapp.com",
script: "server.js",
cwd: "/var/www/myapp.com",
env: { PORT: 3003, HOSTNAME: "127.0.0.1" },
// Next.js loads .env from cwd automatically
}],
};
Important: Never commit .env files to Git. Add them to .gitignore and manage them directly on the server.
Managing Multiple Apps
The PM2 ecosystem file makes it easy to run multiple apps on one server:
module.exports = {
apps: [
{
name: "ahmedelywa.com",
script: "server.js",
cwd: "/var/www/ahmedelywa.com",
env: { PORT: 3003, HOSTNAME: "127.0.0.1" },
},
{
name: "ek-website",
script: "server.js",
cwd: "/var/www/ek-website",
env: { PORT: 3005, HOSTNAME: "127.0.0.1" },
},
{
name: "gold-price-app",
script: "server.js",
cwd: "/var/www/gold-price-app",
env: { PORT: 3002, HOSTNAME: "127.0.0.1" },
},
],
};
Each app gets its own Nginx config, SSL certificate, and PM2 process. I currently run 5 production apps on a single server with this setup.
Monitoring
PM2 provides built-in monitoring:
# View all processes
pm2 list
# View logs
pm2 logs myapp.com
# Monitor CPU/memory in real-time
pm2 monit
# View detailed process info
pm2 show myapp.com
For a quick health check from anywhere:
ssh hetzner "sudo -u deploy pm2 list"
The Complete Flow
Here's what happens when you push code:
- You push to
mainon GitHub - GitHub Actions triggers the deploy workflow
- The runner builds your app with Bun
- The runner joins your Tailscale network
- It SSH's into your server via the Tailscale IP on port 1993
- rsync copies the new build to
/var/www/ - PM2 restarts the app
- Nginx serves the new version immediately
Zero downtime in practice — PM2 restarts are sub-second, and Nginx buffers requests during the transition.
Series Wrap-Up
Over this 5-part series, we've built a complete remote development and deployment platform:
- Chose a Hetzner server with the right specs for development workloads
- Set up Ubuntu with dev tools, Docker, and a clean directory structure
- Secured everything with Tailscale VPN — SSH invisible to the internet
- Installed Claude Code and built a tmux-based development workflow
- Automated deployment with GitHub Actions deploying through Tailscale
The total cost: about $40-60/month for a Hetzner dedicated server that handles development, CI/CD, and production hosting for multiple apps. Compare that to separate services for each concern and it's a significant saving.
More importantly, you can work from anywhere. Open your laptop at a coffee shop, SSH into the server, attach to tmux, and you're exactly where you left off. No syncing, no rebuilding, no lost context.
Series Navigation
- Choosing Your Server
- Initial Server Setup
- Security with Tailscale VPN
- Claude Code & Development Workflow
- Production Deployment with GitHub Actions & PM2 (you are here)