<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mushood</title>
    <description>The latest articles on DEV Community by Mushood (@mmushood).</description>
    <link>https://dev.to/mmushood</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4059623%2F1ee419f0-25e0-4a2c-bc99-97d00d86ef6e.jpg</url>
      <title>DEV Community: Mushood</title>
      <link>https://dev.to/mmushood</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mmushood"/>
    <language>en</language>
    <item>
      <title>I Stopped SSH-ing Into My Server to Deploy — Here’s the 30 Lines of YAML That Replaced It</title>
      <dc:creator>Mushood</dc:creator>
      <pubDate>Mon, 03 Aug 2026 22:54:55 +0000</pubDate>
      <link>https://dev.to/mmushood/i-stopped-ssh-ing-into-my-server-to-deploy-heres-the-30-lines-of-yaml-that-replaced-it-137</link>
      <guid>https://dev.to/mmushood/i-stopped-ssh-ing-into-my-server-to-deploy-heres-the-30-lines-of-yaml-that-replaced-it-137</guid>
      <description>&lt;p&gt;&lt;em&gt;Automatic Node.js deployments on any VPS, using GitHub Actions, PM2, and a shell script that lives on the server.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Last month I merged a one-line fix at nine o'clock on a Friday night.&lt;/p&gt;

&lt;p&gt;Then came the ritual. Open a terminal. SSH into the box. &lt;code&gt;cd&lt;/code&gt; into the project. &lt;code&gt;git pull&lt;/code&gt;. &lt;code&gt;npm install&lt;/code&gt;. &lt;code&gt;pm2 restart backend-api&lt;/code&gt;. Sit there for a few seconds waiting to find out whether anything caught fire.&lt;/p&gt;

&lt;p&gt;Six commands for a single changed line.&lt;/p&gt;

&lt;p&gt;I've run that sequence something like four hundred times, and every run carries a small tax. Wrong terminal tab. Wrong server. Skipping &lt;code&gt;npm install&lt;/code&gt; and then burning twenty minutes hunting a missing dependency that was sitting in &lt;code&gt;package.json&lt;/code&gt; the whole time. The commands aren't difficult. Remembering all of them, in order, while tired, is where it goes wrong.&lt;/p&gt;

&lt;p&gt;In an earlier post I walked through getting a Node.js backend live on a VPS behind &lt;a href="https://medium.com/@khawaja.muhammad.mushood/how-to-deploy-a-node-js-backend-the-correct-way-with-nginx-pm2-on-any-vps-b8051193f90a" rel="noopener noreferrer"&gt;Nginx with PM2&lt;/a&gt;. That guide stopped at the manual deploy.&lt;/p&gt;

&lt;p&gt;This one throws the manual deploy away.&lt;/p&gt;

&lt;p&gt;By the time you finish reading, a merge into &lt;code&gt;main&lt;/code&gt; will ship your backend without you. SSH becomes something you use when something's genuinely broken, not something you use on Fridays.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pipeline Actually Does
&lt;/h2&gt;

&lt;p&gt;The shape of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You merge a PR into main
  |
  v
GitHub Actions runner starts
  |
  v
Runner SSHs into your VPS with a deploy key
  |
  v
deploy.sh runs: git pull -&amp;gt; npm ci -&amp;gt; pm2 reload
  |
  v
App is live, no downtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part worth internalizing: &lt;strong&gt;GitHub Actions never builds your app or ships files anywhere.&lt;/strong&gt; It logs into your server and tells the server to go update itself. That's the whole trick.&lt;/p&gt;

&lt;p&gt;For one VPS, this is almost always the right call. No container registry. No artifact uploads. No orchestration layer you'll spend a weekend learning. A key, a script, and a workflow file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Need Before Any of This Works
&lt;/h2&gt;

&lt;p&gt;I'm assuming you've already got:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Node.js backend running on a VPS under PM2&lt;/li&gt;
&lt;li&gt;The project cloned on that server from a Git repository&lt;/li&gt;
&lt;li&gt;Nginx sitting in front of it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those are missing, stop here and go set them up. Automating a deployment that doesn't work by hand doesn't give you a pipeline — it gives you two broken things instead of one, and no clear way to tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Generate a Deploy Key That Only Deploys
&lt;/h2&gt;

&lt;p&gt;Never reuse your personal SSH key for this. Not once, not "just to test it."&lt;/p&gt;

&lt;p&gt;The moment your personal private key lands in GitHub Secrets, every collaborator with admin rights on that repo is one workflow file away from everything that key touches. That's not a hypothetical — it's a two-minute exercise for anyone who wants to try.&lt;/p&gt;

&lt;p&gt;Make a dedicated key on your &lt;strong&gt;local machine&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"github-actions-deploy"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/deploy_key &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get two files out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.ssh/deploy_key&lt;/code&gt; — the private half. This one goes into GitHub Secrets.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.ssh/deploy_key.pub&lt;/code&gt; — the public half. This one goes on your server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;-N ""&lt;/code&gt; flag creates the key without a passphrase. Yes, a passphrase would be stronger. No, GitHub Actions can't type one at three in the morning. So this key ships bare — which is precisely the argument for giving it its own narrow job and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install the Public Key on Your Server
&lt;/h2&gt;

&lt;p&gt;Push it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/deploy_key.pub your-user@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;ssh-copy-id&lt;/code&gt; on your machine? Do it the long way. Print the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/deploy_key.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the key onto its own line and save.&lt;/p&gt;

&lt;p&gt;Before you go anywhere near GitHub, prove the key works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/deploy_key your-user@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that doesn't log you in, nothing downstream will either. Sort it out now, at the layer where the error messages still make sense to a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Store Four Secrets in GitHub
&lt;/h2&gt;

&lt;p&gt;In your repository, head to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings → Secrets and variables → Actions → New repository secret&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add these four:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your server IP or domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the SSH user, e.g. &lt;code&gt;ubuntu&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_SSH_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;entire&lt;/strong&gt; contents of &lt;code&gt;~/.ssh/deploy_key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_PORT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;22&lt;/code&gt;, or whatever custom SSH port you run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For that third one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/deploy_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy all of it. The &lt;code&gt;-----BEGIN OPENSSH PRIVATE KEY-----&lt;/code&gt; line. The &lt;code&gt;-----END OPENSSH PRIVATE KEY-----&lt;/code&gt; line. The trailing newline after it.&lt;/p&gt;

&lt;p&gt;Roughly half the &lt;code&gt;Permission denied (publickey)&lt;/code&gt; failures you'll ever stare at in a deploy log trace back to someone who grabbed only the middle chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Put the Deploy Logic in a Script on the Server
&lt;/h2&gt;

&lt;p&gt;You &lt;em&gt;could&lt;/em&gt; stuff every deploy command straight into the workflow YAML.&lt;/p&gt;

&lt;p&gt;Don't.&lt;/p&gt;

&lt;p&gt;Keep them in a script that lives on the server, because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can run it by hand whenever you need to&lt;/li&gt;
&lt;li&gt;You can test changes without pushing a commit to trigger anything&lt;/li&gt;
&lt;li&gt;Editing your deploy steps stops meaning editing your CI config&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano ~/deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;APP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/your-backend-repo"&lt;/span&gt;
&lt;span class="nv"&gt;APP_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"backend-api"&lt;/span&gt;
&lt;span class="nv"&gt;BRANCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Deploying &lt;/span&gt;&lt;span class="nv"&gt;$APP_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Pulling latest code"&lt;/span&gt;
git fetch origin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; &lt;span class="s2"&gt;"origin/&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Installing dependencies"&lt;/span&gt;
npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Reloading PM2"&lt;/span&gt;
pm2 reload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--update-env&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Deploy complete"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short script, but several of those lines are carrying real weight.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;set -euo pipefail&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Halts the script the instant something fails. Leave it out and &lt;code&gt;npm ci&lt;/code&gt; can blow up while the script cheerfully reloads PM2 anyway — handing you a green checkmark on a deploy that just took production down.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git reset --hard&lt;/code&gt; rather than &lt;code&gt;git pull&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;git pull&lt;/code&gt; chokes on merge conflicts, which is exactly what you get when someone edits a file directly on the server. &lt;code&gt;reset --hard&lt;/code&gt; forces the working tree to match the remote branch and asks no questions. Your server shouldn't be holding local changes in the first place. If it is, that's the actual bug — not the thing the deploy script needs to negotiate with.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;npm ci&lt;/code&gt; rather than &lt;code&gt;npm install&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ci&lt;/code&gt; installs precisely what's pinned in &lt;code&gt;package-lock.json&lt;/code&gt; and fails loudly when the lockfile has drifted. &lt;code&gt;install&lt;/code&gt; will quietly resolve to versions you never tested against, which is a fun problem to discover in production at 2am.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pm2 reload&lt;/code&gt; rather than &lt;code&gt;pm2 restart&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;restart&lt;/code&gt; kills the process, then starts a fresh one — and requests arriving in that gap get refused. &lt;code&gt;reload&lt;/code&gt; spins up the replacement before retiring the old process. On a single instance the window shrinks to almost nothing; in cluster mode it really is zero downtime.&lt;/p&gt;

&lt;p&gt;Now run it yourself, once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get a clean run here before you automate anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Write the Workflow
&lt;/h2&gt;

&lt;p&gt;Back on your local machine, inside the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; .github/workflows
nano .github/workflows/deploy.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Backend&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production-deploy&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to VPS&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy over SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_PORT }}&lt;/span&gt;
          &lt;span class="na"&gt;script_stop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;export NVM_DIR="$HOME/.nvm"&lt;/span&gt;
            &lt;span class="s"&gt;[ -s "$NVM_DIR/nvm.sh" ] &amp;amp;&amp;amp; \. "$NVM_DIR/nvm.sh"&lt;/span&gt;
            &lt;span class="s"&gt;bash ~/deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit it and push.&lt;/p&gt;

&lt;p&gt;Two lines in there deserve a closer look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt;&lt;/strong&gt; puts a "Run workflow" button in your Actions tab. The first time you need to redeploy without producing a new commit — and that day comes — you'll be glad it's sitting there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;concurrency&lt;/code&gt;&lt;/strong&gt; keeps two deploys from stepping on each other. Merge two pull requests thirty seconds apart without it and you've got two &lt;code&gt;git reset --hard&lt;/code&gt; calls racing in the same directory. Setting &lt;code&gt;cancel-in-progress: false&lt;/code&gt; tells the second run to queue up and wait rather than get discarded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Merge Something and Watch
&lt;/h2&gt;

&lt;p&gt;Push a change into &lt;code&gt;main&lt;/code&gt;, then open the &lt;strong&gt;Actions&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;You'll watch the job spin up, connect over SSH, and stream every line of &lt;code&gt;deploy.sh&lt;/code&gt; straight into the log. Same output you'd have seen in your terminal, minus the terminal.&lt;/p&gt;

&lt;p&gt;Green? You're finished. Go delete that text file of deploy commands you've been keeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Error Nearly Everyone Hits First
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash: line 1: pm2: command not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one catches almost everybody, and it isn't a mistake on your part.&lt;/p&gt;

&lt;p&gt;When you SSH in as a person, bash reads &lt;code&gt;~/.bashrc&lt;/code&gt; and &lt;code&gt;~/.profile&lt;/code&gt; — and if you installed Node through nvm, that's where &lt;code&gt;pm2&lt;/code&gt; gets added to your &lt;code&gt;PATH&lt;/code&gt;. When GitHub Actions runs a command over SSH, it opens a &lt;strong&gt;non-interactive shell&lt;/strong&gt;. Those files never get sourced. Your &lt;code&gt;PATH&lt;/code&gt; shows up nearly empty, and as far as that shell is concerned, &lt;code&gt;pm2&lt;/code&gt; doesn't exist.&lt;/p&gt;

&lt;p&gt;The two &lt;code&gt;NVM_DIR&lt;/code&gt; lines in the workflow above solve it by loading nvm by hand before the script runs.&lt;/p&gt;

&lt;p&gt;Installed Node from NodeSource instead of nvm, like in my VPS post? Then there's no nvm to load, those lines are harmless no-ops, and &lt;code&gt;pm2&lt;/code&gt; should already be sitting at &lt;code&gt;/usr/bin/pm2&lt;/code&gt;. Still not found? Track it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which pm2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And use the full path inside &lt;code&gt;deploy.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/bin/pm2 reload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--update-env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four Other Things That Will Trip You Up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Host key verification failed&lt;/code&gt;.&lt;/strong&gt; Your server has never spoken to the Git remote before and doesn't trust its fingerprint. SSH in manually, run &lt;code&gt;git fetch&lt;/code&gt; once, accept the fingerprint, and it never asks again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A private repo the server can't clone.&lt;/strong&gt; The server needs read access of its own. Generate a key &lt;em&gt;on the server&lt;/em&gt;, then add the public half under &lt;strong&gt;Repository Settings → Deploy keys&lt;/strong&gt;. Leave write access switched off — the server only ever needs to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your &lt;code&gt;.env&lt;/code&gt; isn't on the server.&lt;/strong&gt; Good. It's gitignored and it should stay gitignored. The file belongs on the server and nowhere else, and nothing in this pipeline goes near it. That's the design working, not a gap in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;npm ci&lt;/code&gt; failing with a lockfile error.&lt;/strong&gt; Your &lt;code&gt;package-lock.json&lt;/code&gt; has fallen out of sync. Run &lt;code&gt;npm install&lt;/code&gt; locally, commit the refreshed lockfile, push again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Upgrades Worth Fifteen Minutes
&lt;/h2&gt;

&lt;p&gt;Once the basic pipeline is green, these are the additions that pay for themselves fastest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run your tests first
&lt;/h3&gt;

&lt;p&gt;Add a job the deploy depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# ... the SSH step from before&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;needs: test&lt;/code&gt; means a red test suite blocks the deploy outright. No override, no judgment call at midnight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a health check
&lt;/h3&gt;

&lt;p&gt;A deploy that completed isn't the same thing as an app that's serving traffic. Tack this onto the end of &lt;code&gt;deploy.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; https://your-domain.com/health &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check failed"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a broken release surfaces as a red X in GitHub within seconds, instead of as a support ticket six hours later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lock down the branch that triggers all this
&lt;/h3&gt;

&lt;p&gt;Think about what you've just built: anyone who can push to &lt;code&gt;main&lt;/code&gt; can push straight to production. That's a lot of authority to leave sitting out in the open. I wrote a separate piece on &lt;a href="https://medium.com/@khawaja.muhammad.mushood/shielding-your-production-code-how-to-restrict-github-pushes-to-a-staging-only-workflow-1d81ec9375f4" rel="noopener noreferrer"&gt;restricting pushes and routing everything through a staging workflow&lt;/a&gt; — worth reading before you forget this part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Actually Gained
&lt;/h2&gt;

&lt;p&gt;Manual deployment isn't risky because the commands are hard. It's risky because it runs on your memory — every step, in the right order, every single time, including at 11pm on a Friday when you just want to close the laptop.&lt;/p&gt;

&lt;p&gt;A pipeline takes that memory and writes it down where it can't forget.&lt;/p&gt;

&lt;p&gt;Here's what's now in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated key that can deploy and do nothing else&lt;/li&gt;
&lt;li&gt;A script on the server you can still run by hand when you want to&lt;/li&gt;
&lt;li&gt;A workflow that fires on merge, refuses to run twice at once, and streams its logs somewhere you can actually read them&lt;/li&gt;
&lt;li&gt;A reload instead of a restart, so nobody hits a refused connection mid-deploy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup runs about twenty minutes. You'll make that back before the week is out — and the fourth or fifth time you merge a fix and simply close the tab, it stops feeling like automation and starts feeling like the way it should have worked all along.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>backend</category>
      <category>cicd</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>How to Deploy a Node.js Backend the Correct Way with Nginx + PM2 on Any VPS</title>
      <dc:creator>Mushood</dc:creator>
      <pubDate>Sun, 02 Aug 2026 23:02:57 +0000</pubDate>
      <link>https://dev.to/mmushood/how-to-deploy-a-nodejs-backend-the-correct-way-with-nginx-pm2-on-any-vps-2f8e</link>
      <guid>https://dev.to/mmushood/how-to-deploy-a-nodejs-backend-the-correct-way-with-nginx-pm2-on-any-vps-2f8e</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5mf7gpq382i075mbs8i.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5mf7gpq382i075mbs8i.webp" alt=" " width="800" height="450"&gt;&lt;/a&gt;# Deploying a Node.js Backend in Production with PM2 &amp;amp; Nginx&lt;/p&gt;

&lt;p&gt;Deploying a Node.js backend is easy until you do it on a real VPS server.&lt;/p&gt;

&lt;p&gt;Locally, everything works perfectly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API launches, connects to the database, Postman returns the correct response, and everything looks good.&lt;/p&gt;

&lt;p&gt;But once you move your project to a production server, you'll quickly face questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What happens if the server reboots?&lt;/li&gt;
&lt;li&gt;Will the application restart automatically?&lt;/li&gt;
&lt;li&gt;Should users access port &lt;code&gt;5000&lt;/code&gt; directly?&lt;/li&gt;
&lt;li&gt;How do you configure HTTPS?&lt;/li&gt;
&lt;li&gt;Where do logs go?&lt;/li&gt;
&lt;li&gt;How should you restart the application after deployment?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are common mistakes during a first production deployment.&lt;/p&gt;

&lt;p&gt;Many beginners simply run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While this works for testing, it is &lt;strong&gt;not suitable for production&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Problems with this approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The process may stop when your SSH session ends.&lt;/li&gt;
&lt;li&gt;If the app crashes, it won't restart automatically.&lt;/li&gt;
&lt;li&gt;After a server reboot, your application stays offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, a proper production setup uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PM2&lt;/strong&gt; — Process manager for Node.js&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx&lt;/strong&gt; — Reverse proxy and web server&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Production Architecture
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
   │
   ▼
Nginx (80 / 443)
   │
   ▼
Node.js App (localhost:5000)
   │
   ▼
PM2 Process Manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Users should never communicate directly with your Node.js application.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of exposing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://your-server-ip:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users should access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-domain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nginx receives the request, handles HTTP/HTTPS, and forwards it to your backend.&lt;/p&gt;




&lt;h1&gt;
  
  
  Step 1 — Connect to Your Server
&lt;/h1&gt;

&lt;p&gt;If using the root user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if using Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh ubuntu@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;your-server-ip&lt;/code&gt; with your VPS IP address.&lt;/p&gt;




&lt;h1&gt;
  
  
  Step 2 — Update Your Server
&lt;/h1&gt;

&lt;p&gt;Always update the package list first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 3 — Install Node.js
&lt;/h1&gt;

&lt;p&gt;For Node.js v20:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://deb.nodesource.com/setup_20.x | &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; bash -
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nodejs &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-v&lt;/span&gt;
npm &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 4 — Clone Your Backend Project
&lt;/h1&gt;

&lt;p&gt;Clone your repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/your-username/your-backend-repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move into the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;your-backend-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your application uses environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PORT=5000
DATABASE_URL=your_database_url
JWT_SECRET=your_secret_key
NODE_ENV=production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never commit your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.gitignore&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 5 — Test the Application
&lt;/h1&gt;

&lt;p&gt;If you have a start script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This verifies that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Environment variables are correct&lt;/li&gt;
&lt;li&gt;Database connection works&lt;/li&gt;
&lt;li&gt;Dependencies are installed&lt;/li&gt;
&lt;li&gt;Production configuration is valid&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stop the application afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CTRL + C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 6 — Install PM2
&lt;/h1&gt;

&lt;p&gt;Install PM2 globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;pm2 &lt;span class="nt"&gt;-g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start your application.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;server.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; backend-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or using the npm start script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start npm &lt;span class="nt"&gt;--name&lt;/span&gt; backend-api &lt;span class="nt"&gt;--&lt;/span&gt; start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 7 — Enable Auto Startup
&lt;/h1&gt;

&lt;p&gt;Generate the startup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 startup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PM2 will display another command.&lt;/p&gt;

&lt;p&gt;Run the generated command (it begins with &lt;code&gt;sudo&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Finally save the process list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your application starts automatically after server reboots.&lt;/p&gt;




&lt;h1&gt;
  
  
  Step 8 — Install Nginx
&lt;/h1&gt;

&lt;p&gt;Install Nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 9 — Configure Nginx
&lt;/h1&gt;

&lt;p&gt;Create a configuration file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/nginx/sites-available/backend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;your-domain.com&lt;/span&gt; &lt;span class="s"&gt;www.your-domain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;"upgrade"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;your-domain.com&lt;/code&gt; with your real domain.&lt;/p&gt;

&lt;p&gt;Enable the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/backend /etc/nginx/sites-enabled/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Step 10 — Configure the Firewall
&lt;/h1&gt;

&lt;p&gt;If you're using &lt;strong&gt;UFW&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;Allow SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Allow HTTP &amp;amp; HTTPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow &lt;span class="s1"&gt;'Nginx Full'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable the firewall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only these ports should be accessible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH&lt;/li&gt;
&lt;li&gt;HTTP (80)&lt;/li&gt;
&lt;li&gt;HTTPS (443)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; expose port &lt;code&gt;5000&lt;/code&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  Step 11 — Configure HTTPS with Certbot
&lt;/h1&gt;

&lt;p&gt;Install Certbot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;certbot python3-certbot-nginx &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the SSL certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; your-domain.com &lt;span class="nt"&gt;-d&lt;/span&gt; www.your-domain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certbot automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates SSL certificates&lt;/li&gt;
&lt;li&gt;Updates your Nginx configuration&lt;/li&gt;
&lt;li&gt;Redirects HTTP → HTTPS&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Useful PM2 Commands
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Check running applications
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Restart application
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 restart backend-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Stop application
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 stop backend-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Delete application
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 delete backend-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  View logs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Open monitoring dashboard
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 monit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Save current process list
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The most useful command during debugging:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Typical Deployment Workflow
&lt;/h1&gt;

&lt;p&gt;Once everything is configured, future deployments become simple.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;your-backend-repo

git pull

npm &lt;span class="nb"&gt;install

&lt;/span&gt;pm2 restart backend-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Why Use PM2 &amp;amp; Nginx?
&lt;/h1&gt;

&lt;p&gt;Instead of exposing your Node.js server directly, using PM2 and Nginx provides several advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automatic application restarts&lt;/li&gt;
&lt;li&gt;Auto-start after server reboot&lt;/li&gt;
&lt;li&gt;SSL/TLS support&lt;/li&gt;
&lt;li&gt;Reverse proxy functionality&lt;/li&gt;
&lt;li&gt;Better logging&lt;/li&gt;
&lt;li&gt;Easier scaling&lt;/li&gt;
&lt;li&gt;Multiple applications or subdomains on one server&lt;/li&gt;
&lt;li&gt;Improved security&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Production Best Practices
&lt;/h1&gt;

&lt;h3&gt;
  
  
  Use environment variables
&lt;/h3&gt;

&lt;p&gt;Good:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwtSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your_secret_here&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Only expose necessary ports
&lt;/h3&gt;

&lt;p&gt;Open only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;22 (SSH)&lt;/li&gt;
&lt;li&gt;80 (HTTP)&lt;/li&gt;
&lt;li&gt;443 (HTTPS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid exposing your application port (&lt;code&gt;5000&lt;/code&gt;).&lt;/p&gt;




&lt;h3&gt;
  
  
  Always monitor logs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Restart after deployment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 restart backend-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Summary
&lt;/h1&gt;

&lt;p&gt;Production deployment is much more than simply starting your Node.js application.&lt;/p&gt;

&lt;p&gt;A production-ready backend should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Automatically restart after crashes&lt;/li&gt;
&lt;li&gt;✅ Start after server reboots&lt;/li&gt;
&lt;li&gt;✅ Use HTTPS&lt;/li&gt;
&lt;li&gt;✅ Hide internal application ports&lt;/li&gt;
&lt;li&gt;✅ Generate accessible logs&lt;/li&gt;
&lt;li&gt;✅ Be easy to update and maintain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using &lt;strong&gt;PM2&lt;/strong&gt; together with &lt;strong&gt;Nginx&lt;/strong&gt; provides a reliable, secure, and scalable setup for deploying Node.js applications on any Ubuntu-based VPS, whether it's AWS EC2, DigitalOcean, Hetzner, Azure, Linode, Contabo, or similar cloud providers.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backenddevelopment</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
