# Remote Dev Server Guide Part 5: Production Deployment with GitHub Actions & PM2 Date: 2026-03-09 · 9 min read Tags: hetzner, github-actions, pm2, nginx, deployment, devops URL: https://ahmedelywa.com/blog/hetzner-remote-dev-part-5-production-deployment --- In [Part 4](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow), 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 ```mermaid 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: ```bash 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_pass`** points to `127.0.0.1:3003` — the app only listens locally - **`Upgrade` headers** support WebSocket connections (needed for Next.js HMR if you ever run dev mode) - **`X-Forwarded-Proto`** ensures your app knows it's behind HTTPS ## Step 2: Set Up SSL with Let's Encrypt Install Certbot and get a free SSL certificate: ```bash 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: ```bash sudo certbot renew --dry-run ``` ## Step 3: Configure PM2 [PM2](https://pm2.keymetrics.io) 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: ```bash sudo npm install -g pm2 ``` Create an ecosystem configuration at `/var/www/ecosystem.config.js`: ```javascript 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: ```bash 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`: ```typescript 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: ```bash 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: ```bash # 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 1. Go to [Tailscale Admin Console](https://login.tailscale.com/admin/settings/oauth) → Settings → OAuth Clients 2. Create a new OAuth client with the `Devices: Write` scope 3. Save the **Client ID** and **Client Secret** ### Create an ACL Tag In your Tailscale ACL policy, add a tag for CI runners: ```json { "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`: ```yaml 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: 1. **Build** — Bun installs dependencies and builds the Next.js app in standalone mode 2. **Prepare** — copies static assets into the standalone output 3. **Tailscale** — the CI runner joins your Tailscale network using OAuth, tagged as `tag:ci` 4. **SSH** — sets up SSH credentials to connect to your server's Tailscale IP on port 1993 5. **Deploy** — rsync copies the standalone build to `/var/www/myapp.com/`, deleting old files (but preserving `.env`) 6. **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: ```bash # Create an env file for your app (as deploy user) sudo -u deploy tee /var/www/myapp.com/.env << 'EOF' DATABASE_URL=postgresql://user:pass@127.0.0.1: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: ```javascript 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: ```javascript 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: ```bash # 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: ```bash ssh hetzner "sudo -u deploy pm2 list" ``` ## The Complete Flow Here's what happens when you push code: 1. You push to `main` on GitHub 2. GitHub Actions triggers the deploy workflow 3. The runner builds your app with Bun 4. The runner joins your Tailscale network 5. It SSH's into your server via the Tailscale IP on port 1993 6. rsync copies the new build to `/var/www/` 7. PM2 restarts the app 8. 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: 1. [**Chose a Hetzner server**](/blog/hetzner-remote-dev-part-1-choosing-your-server) with the right specs for development workloads 2. [**Set up Ubuntu**](/blog/hetzner-remote-dev-part-2-initial-server-setup) with dev tools, Docker, and a clean directory structure 3. [**Secured everything**](/blog/hetzner-remote-dev-part-3-security-with-tailscale) with Tailscale VPN — SSH invisible to the internet 4. [**Installed Claude Code**](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) and built a tmux-based development workflow 5. [**Automated deployment**](/blog/hetzner-remote-dev-part-5-production-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 1. [Choosing Your Server](/blog/hetzner-remote-dev-part-1-choosing-your-server) 2. [Initial Server Setup](/blog/hetzner-remote-dev-part-2-initial-server-setup) 3. [Security with Tailscale VPN](/blog/hetzner-remote-dev-part-3-security-with-tailscale) 4. [Claude Code & Development Workflow](/blog/hetzner-remote-dev-part-4-claude-code-dev-workflow) 5. **Production Deployment with GitHub Actions & PM2** (you are here)