Profile photo of Travis Horn Travis Horn

Rolling Your Own Node.js Continuous Deployment on Linux

2026-05-05
Rolling Your Own Node.js Continuous Deployment on Linux

You don’t need a bloated, expensive CI/CD platform to deploy your Node.js apps. You can roll your own “push-to-deploy” pipeline directly on your VPS. This method is lightweight, strictly adheres to the principle of least privilege, and feels like magic. Here is your complete, step-by-step guide to building it from scratch.

Note: I wrote a similar guide to this a few years ago, but this is the updated setup I’m currently using, which is simpler and more secure. It takes into account all of the lessons I’ve learned since I’ve been maintaining CD on my own like this.

This guide assumes…

  • you are using a Debian-based system,
  • you have root access (sudo privileges will work great),
  • git, nodejs, and npm are installed, and
  • you already have a Node.js project that serves HTTP content.

Create Dedicated Users

We need two separate users: one to securely handle Git connections, and one to securely run the application.

  1. Create a git user:

    sudo useradd --system --shell /usr/bin/git-shell git
  2. Create a myapp user:

    sudo useradd --system --shell /usr/sbin/nologin --home-dir /nonexistent myapp

    Important: Don’t actually name it myapp; name it after the app you’re deploying. You should make one of these users per app.

Configure SSH Access

You need to allow your development machine(s) to authenticate as the git user.

  1. Create a .ssh directory for the git user:

    sudo mkdir -p /home/git/.ssh
  2. Secure the directory so only the git user can read/write/execute it:

    sudo chmod 700 /home/git/.ssh
  3. Open the authorized keys file in your text editor:

    sudo edit /home/git/.ssh/authorized_keys
  4. Paste your developer public SSH key(s) (e.g., the contents of ~/.ssh/id_ed25519.pub) into this file and save. Don’t have a public SSH key? Check out this guide.

  5. Secure the file so only the git user can read/write to it:

    sudo chmod 600 /home/git/.ssh/authorized_keys
  6. Set the git user as the owner of .ssh and its contents:

    sudo chown -R git:git /home/git/.ssh

Set Up an Application Directory

We need to create a directory structure where the app will live. You should do these steps for every app you plan to deploy.

  1. Create a directory to hold scripts related to the app:

    sudo mkdir -p /var/www/myapp/bin
  2. Create a directory that will hold the production code:

    sudo mkdir /var/www/myapp/releases
  3. Open a new .env file in your editor:

    sudo edit /var/www/myapp/.env

    Paste in your app’s environment variables. We’ll have Systemd read this and feed the variables to your app later.

  4. Secure this file so only the owner can read it:

    sudo chmod 600 /var/www/myapp/.env
  5. Give ownership of this entire structure to the myapp user:

    sudo chown -R myapp:myapp /var/www/myapp

Create a Deployment Script

This script handles extracting your app’s code, installing dependencies, and swapping a symlink that points to the latest release.

  1. Create the script:

    sudo edit /var/www/myapp/bin/deploy.sh
  2. Paste the following code:

    #!/bin/bash
    
    # Stop execution immediately if any command fails
    set -e 
    
    APP_DIR="/var/www/myapp"
    RELEASE_NAME=$(date +%Y%m%d%H%M%S)
    NEW_RELEASE_DIR="$APP_DIR/releases/$RELEASE_NAME"
    
    # Create new release directory
    mkdir -p "$NEW_RELEASE_DIR"
    
    # Extract the tar archive from standard input
    tar -mx -C "$NEW_RELEASE_DIR"
    
    # Install dependencies
    cd "$NEW_RELEASE_DIR"
    npm ci --cache /tmp/npm-cache
    
    # Atomic Symlink Swap
    ln -sfn "$NEW_RELEASE_DIR" "$APP_DIR/current"
    
    # Restart the systemd service
    sudo /usr/bin/systemctl restart myapp
    
    # Keep only the 5 most recent releases
    ls -dt $APP_DIR/releases/* | tail -n +6 | xargs -d '\n' rm -rf --
  3. Make it executable:

    sudo chmod +x /var/www/myapp/bin/deploy.sh
  4. Set ownership:

    sudo chown myapp:myapp /var/www/myapp/bin/deploy.sh

You may have noticed this line in the deployment script:

tar -mx -C "$NEW_RELEASE_DIR"

That line means the script expects a compressed tar archive to be passed to it. We’ll do that with a post-receive Git hook.

Configure a Bare Git Repository

This is the repository on the server to which you will push from your development machine.

  1. Create a directory that will store a bare Git repository:

    sudo mkdir -p /srv/git/myapp
  2. Change into that directory:

    cd /srv/git/myapp
  3. Initialize a bare Git repository:

    sudo git init --bare
  4. Create a post-receive hook:

    sudo edit /srv/git/myapp/hooks/post-receive
  5. Paste this code, which packages the code and pipes it to the deploy script:

    #!/bin/bash
    set -euo pipefail
    
    APP_USER="myapp"
    DEPLOY_SCRIPT="/var/www/myapp/bin/deploy.sh"
    DEFAULT_REF=$(git symbolic-ref HEAD)
    
    while read oldrev newrev refname
    do
       if [ "$refname" == "$DEFAULT_REF" ]; then
          git archive "$newrev" | sudo -u "$APP_USER" "$DEPLOY_SCRIPT"
       fi
    done

    Basically, when the default branch (e.g., main) receives a push, the hook compresses the code into a tar archive and passes it to the deployment script as the app user.

  6. Make the hook executable:

    sudo chmod +x /srv/git/myapp/hooks/post-receive
  7. Give ownership to the git user:

    sudo chown -R git:git /srv/git/myapp

Configure Sudo Rules

We need to allow the new users we created to execute specific commands without being prompted for a password.

  1. Use visudo to safely create a new sudoers file:

    sudo visudo -f /etc/sudoers.d/deployment
  2. Add these two lines to the file and save:

    git ALL=(myapp) NOPASSWD: /var/www/myapp/bin/deploy.sh
    myapp ALL=NOPASSWD: /usr/bin/systemctl restart myapp

    These two lines keep the server secure using the Principle of Least Privilege.

    The first line allows the git user to execute deploy.sh as the myapp user. You’ll have to place one of these lines for each app you deploy.

    The second line allows the myapp user to restart the app. You’ll also need one of these per app.

Create a Systemd Service

This tells Debian how to keep your Node.js application running in the background. This is less powerful but simpler than managing processes through something like PM2.

  1. Create a service file:

    sudo edit /etc/systemd/system/myapp.service
  2. Paste the configuration:

    [Unit]
    Description=This is my web app.
    After=network.target
    
    [Service]
    User=myapp
    Group=myapp
    
    WorkingDirectory=/var/www/myapp/current
    
    ExecStart=/usr/bin/npm start
    
    Restart=always
    RestartSec=10
    
    EnvironmentFile=/var/www/myapp/.env
    
    PrivateTmp=true
    NoNewPrivileges=true
    
    [Install]
    WantedBy=multi-user.target

    This service file assumes you have a script in package.json called start which launches your app. If you don’t, just edit the ExecStart line. For example, if you want to execute the index.js file with Node, make the line read ExecStart=/usr/bin/node index.js.

    Also notice the WorkingDirectory points to current. Remember that this is a symlink managed by the deployment script that always points to the most recent release.

  3. Tell systemd to read the new file:

    sudo systemctl daemon-reload
  4. Enable it to start on boot:

    sudo systemctl enable myapp.service

    This enables start-on-boot but doesn’t start the service immediately. That’s good because it would fail right now, until we push our first deployment.

Your First Deployment!

Now, go to your development machine where your application’s source code lives.

  1. Navigate to your local project directory:

    cd ~/myapp
  2. Add the remote server (replace YOUR_SERVER_IP with the actual IP or hostname):

    git remote add origin git@YOUR_SERVER_IP:/srv/git/myapp
  3. Push the code to deploy!

    git push origin main

If everything was set up correctly, you will see the output of the deploy.sh script stream right back to your local terminal. When you push like this, quite a few things happen:

  1. The latest code is pushed to the server.
  2. The post-receive hook
  • packages the code into a tarball stream, and
  • pipes it to the deployment script.
  1. The deployment script
  • creates a new timestamped directory inside releases/,
  • unpacks the code into it,
  • installs dependencies,
  • updates the current symlink to point to the new release,
  • restarts the myapp systemd service, and
  • deletes old releases

A flowchart diagram showing the developer initiating deployment with git push
origin main, code pushed to server (bare Git repo) over SSH as git user,
lands in /srv/git/myapp, hook triggered - packages code with git archive main |
deploy.sh, deployment script runs as myapp user to create release dir in
/var/www/myapp/releases.., extracts code with tar -mx .. myapp/releases..,
installs dependencies with npm ci, swaps symlink with ln releases.// current,
restarts the app with systemctl restart myapp, cleans old releases with ls |
tail | xargs rm .., and systemd runs updated app because
WorkingDirectory=/var/www/myapp/current

Your app is now live!

Congratulations, you just built a deployment pipeline without relying on third-party services. You now have a custom deployment process that is secure, automated, and fast. Go ahead, make a quick code change, type git push, and watch your server do the heavy lifting.

Cover photo by Bhautik Patel on Unsplash.

Here are some more articles you might like: