~Blog / Jordane Takoukam
DevOpsLevel Intermediate

Deploy an application on a VPS, from scratch to CI/CD

The complete procedure I replay on every new project: from a bare VPS to an application served over HTTPS, redeployed on its own on every git push. Every command is copy-paste ready, in order.

Author
Jordane Takoukam
Published on
Reading time
21 min read
#VPS#SSH#Security#Nginx#Node.js#PM2#CI/CD

Deploying an application on a VPS is not a single task, but a dozen small steps that have to follow one another in the right order. Miss one and you end up with a 502 Bad Gateway or a Permission denied without knowing where it comes from.

This guide walks through the procedure end to end: we start from a freshly purchased server and finish with an application served over HTTPS, supervised by PM2, and redeployed automatically on every git push thanks to GitHub Actions.

What you need

  • A VPS running Ubuntu (22.04 or 24.04), with its IP address and the root password provided by the host.
  • A domain name whose A DNS record points to the VPS IP address.
  • A GitHub repository holding the project code.
  • A Windows PC with OpenSSH (included by default on Windows 10/11).
I

Create an SSH alias to connect to the server easily

Goal: replace typing the IP and password with a single memorable command (ssh mon-vps) and a key-based connection, far more secure.

1Open the SSH configuration folder

On Windows, the .ssh folder lives at the root of the user profile. We move into it from PowerShell.

PowerShell · Windows
cd C:\Users\Jordane\.ssh

If it does not exist yet, create it with mkdir C:\Users\Jordane\.ssh.

2Generate the SSH key pair

We generate a 4096-bit RSA key. The -C option adds a comment (handy to identify the key later) and -f sets the output file name.

PowerShell · Windows
ssh-keygen -t rsa -b 4096 -C "personal vps" -f mon-vps

At the passphrase prompt, you can press Enter to leave it empty. Two files appear: mon-vps (private key, keep it secret) and mon-vps.pub (public key, free to share).

3Declare the alias in the SSH config file

Still inside .ssh, edit (or create) the config file, with no extension. It maps a short name to an IP, a user and a key.

C:\Users\Jordane\.ssh\config
# My personal VPS
Host mon-vps
    HostName 147.x.x.x
    User root
    IdentityFile ~/.ssh/mon-vps

4Display the public key

Print the public key contents so you can copy them in the next step.

PowerShell · Windows
type mon-vps.pub

5Install the public key on the server

Connect a first time with the password of the VPS (the one provided by the host), then install the public key.

PowerShell · Windows
ssh mon-vps

Once connected to the server, run:

VPS · bash
# 1. Create the .ssh folder if it does not exist
mkdir -p ~/.ssh

# 2. Open the authorized_keys file
nano ~/.ssh/authorized_keys
# -> paste the contents of mon-vps.pub
# -> save with Ctrl+O then Enter, quit with Ctrl+X

# 3. Lock down the permissions (SSH rejects loose rights)
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

6Verify the password-free connection

Disconnect, then start the connection again: it should now open without asking for a password.

PowerShell · Windows
exit
ssh mon-vps
II

Harden the server

Goal: a VPS exposed on the Internet is scanned non-stop. We create a non-root user, cut off password access and close every unnecessary port.

1Create a non-root user with sudo rights

Working as root day to day is risky: the slightest mistake runs with full powers. We create a dedicated user, here deploy.

VPS · bash (as root)
# Create the user (a password will be requested)
adduser deploy

# Grant administration rights
usermod -aG sudo deploy

# Copy the authorized SSH key to the new user
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Update the alias on the Windows side to use this account (User deploy instead of User root in .ssh/config), then test ssh mon-vps.

2Configure the UFW firewall

UFW only lets through what you explicitly allow: SSH, HTTP and HTTPS. Everything else is blocked.

VPS · bash
# Allow SSH, HTTP and HTTPS
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'

# Enable the firewall
sudo ufw enable

# Check the active rules
sudo ufw status verbose

3Disable root and password login

Edit the SSH server configuration to allow key-based authentication only.

VPS · bash
sudo nano /etc/ssh/sshd_config

Locate and adjust these three directives:

/etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes

Then reload the SSH service to apply:

VPS · bash
sudo systemctl restart ssh

4Install fail2ban (optional but recommended)

fail2ban watches the logs and automatically bans IP addresses that pile up failed login attempts.

VPS · bash
sudo apt install fail2ban -y

# Check that it is running
sudo systemctl status fail2ban
III

Prepare the application environment

Goal: install the stack that will run and serve the application — system up to date, Nginx, Certbot, Node.js 20 and PM2.

1Update the server

VPS · bash
# Refresh the list of available packages
sudo apt update

# Install the updates
sudo apt upgrade -y

# Clean up packages that are no longer needed
sudo apt autoremove -y
sudo apt autoclean

2Install Nginx

Nginx will act as a reverse proxy: it receives web traffic and forwards it to the Node.js application (section VIII).

VPS · bash
sudo apt install nginx -y

# Check that the service is active
sudo systemctl status nginx

At this point, opening http://147.x.x.x in a browser should show the default Nginx welcome page.

3Install Certbot (free SSL certificates)

Certbot obtains and renews Let's Encrypt certificates. The python3-certbot-nginx package adds direct integration with Nginx.

VPS · bash
sudo apt install certbot python3-certbot-nginx -y

# Check the installation
certbot --version

4Install Node.js 20

The official NodeSource repository provides a recent, maintained version, unlike the one in the Ubuntu repositories.

VPS · bash
# Add the official NodeSource repository for Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -

# Install Node.js (npm is included)
sudo apt install -y nodejs

# Check the versions
node -v
npm -v

5Install and configure PM2

PM2 is a process manager: it keeps the application alive, restarts it if it crashes and brings it back up when the server boots.

VPS · bash
# Install PM2 globally
sudo npm install -g pm2

# Generate the startup script
pm2 startup

# Check the version
pm2 -v
IV

Set up the folder layout

Goal: a standard, predictable location for the code, one the CI/CD pipeline will always find in the same place.

The Linux convention is to serve sites from /var/www. We create one folder for the front-end and one for the back-end.

VPS · bash
# Move into the web sites folder
cd /var/www

# Create the project folders
sudo mkdir -p frontend backend

# Hand ownership to the deployment user
sudo chown -R deploy:deploy /var/www/frontend /var/www/backend

The resulting layout:

Target layout
/var/www
├── frontend/   # site / interface code
└── backend/    # API code
V

Link the VPS and GitHub with a dedicated SSH key

Goal: give GitHub Actions its own key to connect to the server, separate from your personal key and revocable at any time.

1Generate a key pair on the VPS

We create an ed25519 key (modern and compact) with no passphrase, since a pipeline cannot type one.

VPS · bash
ssh-keygen -t ed25519 -f ~/.ssh/github_actions -N ""

Two files are created:

Generated files
~/.ssh/github_actions       # private key -> goes into a GitHub secret
~/.ssh/github_actions.pub   # public key -> stays on the server

2Authorize that key on the VPS

Add the public key to the keys the server accepts, and lock down the permissions.

VPS · bash
# Add the public key to the authorized keys
cat ~/.ssh/github_actions.pub >> ~/.ssh/authorized_keys

# Lock down the permissions
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/github_actions

3Retrieve the private key

Print the private key: its contents will go into the GitHub secret SERVER_SSH_KEY in the next section.

VPS · bash
cat ~/.ssh/github_actions
VI

Fill in the GitHub secrets and variables

Goal: give the GitHub repository the information the pipeline needs, separating what is sensitive (secrets) from what is not (variables).

In the GitHub repository, open Settings → Secrets and variables → Actions. The tab offers two sections.

« Secrets » tab: encrypted, never shown again

  • SERVER_IP: the VPS IP address.
  • SERVER_USER: the SSH user, namely deploy.
  • SERVER_SSH_KEY: the github_actions private key copied in section V.
  • SMTP_PASSWORD: the password of the application's email-sending account.

« Variables » tab: in plain text

  • SMTP_USERNAME: the SMTP account login.
  • SMTP_TO: the address that receives the emails.
VII

Write the continuous deployment pipeline

Goal: on every git push to a dedicated branch, GitHub connects to the VPS, pulls the code, installs the dependencies and restarts the application, with no manual step.

1Create the deployment branch

The pipeline will only trigger on a specific branch. We create production locally.

PowerShell · local project
git checkout -b production
git push -u origin production

2Create the workflow file

GitHub Actions reads workflows from a specific location in the repository:

Repository layout
.github/
└── workflows/
    └── deploy.yml

3Define the deployment workflow

Here is a complete, reusable deploy.yml. It connects to the server over SSH, updates the code, installs the dependencies, builds the project and (re)starts it with PM2. Adapt the path, the process name and the build commands to your project.

.github/workflows/deploy.yml
name: Deploy to the VPS

# Triggers on every push to the production branch
on:
  push:
    branches: [production]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /var/www/backend
            git pull origin production
            npm ci
            npm run build
            pm2 reload ecosystem.config.js --update-env
            pm2 save

4Describe the application for PM2

The pipeline calls ecosystem.config.js: this file, at the project root, describes how to start the application. We commit it to the repository.

ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "backend",
      script: "npm",
      args: "start",
      cwd: "/var/www/backend",
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
    },
  ],
};

On the very first deployment, the application is not known to PM2 yet: start it once by hand on the server, then freeze the list.

VPS · bash (first launch)
cd /var/www/backend
pm2 start ecosystem.config.js
pm2 save

# Follow the application logs
pm2 logs backend
VIII

Configure Nginx as a reverse proxy and enable HTTPS

Goal: expose the application to the world through the domain name, and encrypt the traffic with a free SSL certificate.

1Create the domain configuration file

Site configurations live in /etc/nginx/sites-available. We create a file named after the domain.

VPS · bash
sudo nano /etc/nginx/sites-available/api.mon-domaine.com

This configuration receives HTTP traffic and forwards it to the Node.js application, which listens locally on port 3000.

/etc/nginx/sites-available/api.mon-domaine.com
server {
    listen 80;
    server_name api.mon-domaine.com;

    location / {
        proxy_pass http://localhost:3000;
        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;
    }
}

2Enable the site

Nginx only serves the configurations present in sites-enabled. We create a symbolic link there pointing to our file, check the syntax, then reload.

VPS · bash
# Enable the site
sudo ln -s /etc/nginx/sites-available/api.mon-domaine.com \
  /etc/nginx/sites-enabled/

# Check that the configuration is valid
sudo nginx -t

# Reload Nginx to apply
sudo systemctl reload nginx

3Generate the SSL certificate with Certbot

Certbot obtains the certificate for the domain and automatically edits the Nginx configuration to enable HTTPS and redirect HTTP to HTTPS.

VPS · bash
# Obtain and install the certificate
sudo certbot --nginx -d api.mon-domaine.com

# Check that automatic renewal works
sudo certbot renew --dry-run
IX

Troubleshoot the most common errors

Three errors show up on almost every deployment. Here is how to diagnose them quickly.

502 Bad Gateway

Nginx works but cannot reach the application: it is stopped, crashed, or not listening on the expected port. Check the process state and its logs.

VPS · bash
# Is the application running?
pm2 status

# What do its logs say?
pm2 logs backend --lines 50

Permission denied (publickey)

The server rejects the key. Common causes: permissions too loose on ~/.ssh, wrong user in the alias, or the key missing from authorized_keys. Restart the connection in verbose mode to see where it gets stuck.

PowerShell · Windows
ssh -v mon-vps

EADDRINUSE: port already in use

An old instance is still holding port 3000. Identify the offending process, then ask PM2 to start cleanly.

VPS · bash
# What process is holding port 3000?
sudo lsof -i :3000

# Start from a clean slate
pm2 delete backend
pm2 start ecosystem.config.js
pm2 save

Final checklist

Once every section is done, here is what should be true. If a box is unchecked, go back to the matching section.

  • ssh mon-vps connects to the server with no password, on the deploy account.
  • root and password login is disabled; UFW is active.
  • Node.js 20, Nginx, Certbot and PM2 are installed and working.
  • The code lives in /var/www and belongs to deploy.
  • The secrets and variables are filled in on GitHub; deploy.yml and ecosystem.config.js are committed.
  • A git push origin production triggers the deployment automatically.
  • The domain answers over https:// with a valid certificate.
Back to the blogBack to top