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
rootpassword provided by the host. - A domain name whose
ADNS 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).
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.
cd C:\Users\Jordane\.sshIf 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.
ssh-keygen -t rsa -b 4096 -C "personal vps" -f mon-vpsAt 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.
# My personal VPS
Host mon-vps
HostName 147.x.x.x
User root
IdentityFile ~/.ssh/mon-vps4Display the public key
Print the public key contents so you can copy them in the next step.
type mon-vps.pub5Install 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.
ssh mon-vpsOnce connected to the server, run:
# 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_keys6Verify the password-free connection
Disconnect, then start the connection again: it should now open without asking for a password.
exit
ssh mon-vpsHarden 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.
# 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_keysUpdate 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.
# 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 verbose3Disable root and password login
Edit the SSH server configuration to allow key-based authentication only.
sudo nano /etc/ssh/sshd_configLocate and adjust these three directives:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yesThen reload the SSH service to apply:
sudo systemctl restart ssh4Install fail2ban (optional but recommended)
fail2ban watches the logs and automatically bans IP addresses that pile up failed login attempts.
sudo apt install fail2ban -y
# Check that it is running
sudo systemctl status fail2banPrepare 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
# 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 autoclean2Install Nginx
Nginx will act as a reverse proxy: it receives web traffic and forwards it to the Node.js application (section VIII).
sudo apt install nginx -y
# Check that the service is active
sudo systemctl status nginxAt 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.
sudo apt install certbot python3-certbot-nginx -y
# Check the installation
certbot --version4Install Node.js 20
The official NodeSource repository provides a recent, maintained version, unlike the one in the Ubuntu repositories.
# 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 -v5Install 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.
# Install PM2 globally
sudo npm install -g pm2
# Generate the startup script
pm2 startup
# Check the version
pm2 -vSet 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.
# 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/backendThe resulting layout:
/var/www
├── frontend/ # site / interface code
└── backend/ # API codeLink 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.
ssh-keygen -t ed25519 -f ~/.ssh/github_actions -N ""Two files are created:
~/.ssh/github_actions # private key -> goes into a GitHub secret
~/.ssh/github_actions.pub # public key -> stays on the server2Authorize that key on the VPS
Add the public key to the keys the server accepts, and lock down the permissions.
# 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_actions3Retrieve the private key
Print the private key: its contents will go into the GitHub secret SERVER_SSH_KEY in the next section.
cat ~/.ssh/github_actionsFill 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, namelydeploy.SERVER_SSH_KEY: thegithub_actionsprivate 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.
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.
git checkout -b production
git push -u origin production2Create the workflow file
GitHub Actions reads workflows from a specific location in the repository:
.github/
└── workflows/
└── deploy.yml3Define 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.
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 save4Describe 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.
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.
cd /var/www/backend
pm2 start ecosystem.config.js
pm2 save
# Follow the application logs
pm2 logs backendConfigure 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.
sudo nano /etc/nginx/sites-available/api.mon-domaine.comThis configuration receives HTTP traffic and forwards it to the Node.js application, which listens locally on port 3000.
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.
# 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 nginx3Generate 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.
# Obtain and install the certificate
sudo certbot --nginx -d api.mon-domaine.com
# Check that automatic renewal works
sudo certbot renew --dry-runTroubleshoot 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.
# Is the application running?
pm2 status
# What do its logs say?
pm2 logs backend --lines 50Permission 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.
ssh -v mon-vpsEADDRINUSE: port already in use
An old instance is still holding port 3000. Identify the offending process, then ask PM2 to start cleanly.
# What process is holding port 3000?
sudo lsof -i :3000
# Start from a clean slate
pm2 delete backend
pm2 start ecosystem.config.js
pm2 saveFinal 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-vpsconnects to the server with no password, on thedeployaccount.rootand password login is disabled; UFW is active.- Node.js 20, Nginx, Certbot and PM2 are installed and working.
- The code lives in
/var/wwwand belongs todeploy. - The secrets and variables are filled in on GitHub;
deploy.ymlandecosystem.config.jsare committed. - A
git push origin productiontriggers the deployment automatically. - The domain answers over
https://with a valid certificate.