Skip to main content

Command Palette

Search for a command to run...

How I Deployed a Full-Stack Application on a DigitalOcean VM Using Nginx and PM2

Updated
15 min readView as Markdown
How I Deployed a Full-Stack Application on a DigitalOcean VM Using Nginx and PM2
S

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.

Introduction

Until now, I had mostly run my full-stack applications locally using localhost. But this time, I wanted to understand what actually happens when we deploy an application to a real server and make it accessible over the internet.

So, I decided to deploy my full-stack application on a DigitalOcean Virtual Machine running Ubuntu.

During this process, I learned how to create a cloud server, generate SSH keys, and securely connect to the Ubuntu server from my local machine using SSH. After connecting to the server, I installed Node.js using NVM, cloned my project from GitHub, installed the required dependencies, and created a production build of my frontend.

But simply running the frontend and backend on different ports was not enough for a proper production setup. This is where I learned about Nginx and PM2. I used Nginx to serve my frontend on port 80 and later understood how it can work as a reverse proxy to forward requests to a backend running on another port. For the backend, I used PM2 to keep the Node.js application running even after closing my SSH connection.

In this blog, I will walk through the complete deployment process step by step and, more importantly, explain what I learned about Virtual Machines, SSH, Nginx, PM2, ports, and how all these pieces work together to take an application from localhost to a real cloud server.


1. Creating a Virtual Machine (Droplet) on DigitalOcean

What is a Virtual Machine (VM)?

VMs run on a physical server (called the host) but are abstracted through a layer of virtualization software called a hypervisor (e.g., VMware, KVM). This hypervisor divides the host machine’s resources (CPU, memory, storage) into separate virtual machines.

Each VM acts like a completely independent machine, even though they share the underlying hardware. You can run different operating systems and applications in different VMs on the same physical server.

VMs are highly flexible and easy to scale. You can quickly spin up, modify, or delete VMs, and you can consolidate multiple workloads on a single server.

The virtualization layer introduces a slight overhead in terms of performance because the hypervisor needs to manage resources and ensure each VM operates independently. However, with modern hypervisors and powerful hardware, this overhead is minimal.


Important Note About Payment

While creating a Droplet on DigitalOcean, you need to add a payment method. If your card payment is not working, check whether e-commerce and international transactions are enabled on your card through your bank's app or contact your bank for help.


Note: DigitalOcean calls its cloud virtual machines Droplets, while the similar virtual server service in AWS is called EC2 (Elastic Compute Cloud).


Step 1: Choose a Datacenter Region

Select a datacenter region that is closest to your users for better performance and lower latency. Since I am based in India, I selected Bengaluru as my datacenter region.


Step 2: Choose an Image

Next, choose the operating system you want to run on your virtual machine. I selected Ubuntu because it is widely used for servers and has great community support.


Step 3: Choose a Droplet Plan

Next, choose a Droplet plan based on your application's requirements and budget. Since I was deploying a project for learning purposes, I selected a basic plan that provided enough CPU, memory, and storage for my application.


Step 4: Create an SSH Key

Before creating the Droplet, we need a secure way to connect our local computer to the Ubuntu server running in the cloud. For this, we use SSH (Secure Shell).

SSH is a secure protocol that allows us to remotely connect to a server and run commands on it. The communication between our computer and the server is encrypted, which means the data being transferred cannot be easily read by someone intercepting the connection.

SSH mainly supports two common authentication methods:

  • Password-based authentication: You enter a password to access the server.

  • SSH key-based authentication: You use a pair of cryptographic keys called a public key and a private key.

For development teams, SSH keys are generally preferred over sharing a single server password. With SSH keys, each developer can have their own key pair. The public key is added to the server, while the private key stays securely on the developer's computer.

For example, if five developers need access to a server, each developer can have their own SSH key. This also makes access easier to manage because a developer's public key can be removed from the server when they no longer need access.

Generate an SSH Key on Windows

Open PowerShell in regular mode and run:

ssh-keygen

You will be asked where you want to save the key. You can press Enter to use the default location, or provide a custom filename. You can also set a passphrase for additional security.

This creates two files:

  • id_ed25519 → Your private key. Never share this file.

  • id_ed25519.pub → Your public key. This is the key you add to your server.

By default, SSH keys are stored inside the .ssh folder in your user directory.

In PowerShell, run:

cd ~/.ssh

To see all the files:

ls

You will usually see:

  • id_ed25519Private key (never share this)

  • id_ed25519.pubPublic key (safe to add to your server)

To copy your public key to the clipboard in PowerShell, run:

Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard

Now paste the copied public key into the SSH Key field while creating your DigitalOcean Droplet.

Once the Droplet is created with your public key, you can use the corresponding private key from your computer to authenticate and securely connect to the server.


Step 5: Give Your Droplet a Name and Create It

Finally, give your Droplet a meaningful name so you can easily identify it later. After entering the name, click Create Droplet and wait a few moments for DigitalOcean to set up your Ubuntu virtual machine.


After Creating the Droplet

Once the Droplet is successfully created, your DigitalOcean dashboard will look something like the screenshot below. Here, you can see important details about your server, such as its name, IP address, region, and current status.


2. Connect Our Local Machine to the Virtual Machine

Now that our Droplet is ready, we can connect our local machine to the Ubuntu server using SSH.

Open PowerShell or CMD and run:

ssh -i <path-to-private-key> root@<PUBLIC_IP>

For example:

ssh -i ~/.ssh/digitalocean-key root@192.0.2.1

Here:

  • -i tells SSH which private key to use for authentication.

  • root is the username we are using to log in to the server.

  • PUBLIC_IP is the public IP address of our Droplet.

Once the connection is successful, we can run commands on our cloud Ubuntu server directly from our local terminal.

Note: In a typical DigitalOcean Ubuntu Droplet setup, we connect using the root user. On AWS EC2, the default username depends on the selected machine image. For an Ubuntu EC2 instance, it is commonly ubuntu.


3. Install Node.js Using NVM

Now that we are connected to our Ubuntu server, let's install Node.js. I used NVM (Node Version Manager) because it makes it easy to install and manage different versions of Node.js.

You can also follow this DigitalOcean Node.js installation guide.

First, install NVM:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

After installing NVM, reload your shell configuration so that the nvm command becomes available:

source ~/.bashrc

Now install the required Node.js version:

nvm install v24.13.0

Finally, verify that Node.js was installed successfully:

node -v

If the installed version is displayed in the terminal, Node.js is ready to use on our Ubuntu server.


4. Clone the Project Repository

Now that Node.js is installed, let's clone our project from GitHub onto the Ubuntu server.

Run:

git clone https://github.com/shubhamsinghbundela/ShelfLife-Household-Inventory-Tracker.git

After cloning, move inside the project directory:

cd ShelfLife-Household-Inventory-Tracker

Now you can check the project files and folders using:

ls

Since our project contains separate frontend and backend folders, we can navigate to the required folder using cd.

For example:

cd backend

or:

cd frontend

Now our project source code is available on the cloud server, and we can start setting up the frontend and backend.


5. Install Frontend Dependencies and Create the Production Build

Now, let's move into the frontend folder:

cd frontend

Install all the required dependencies:

npm install

Before creating the production build, check the environment file:

cat .env

In development, my environment variable was:

VITE_ENV=development

To edit:

nano .env

Change it to:

VITE_ENV=production

This is important because my Axios configuration uses different API URLs depending on the environment:

const api = axios.create({
  baseURL:
    import.meta.env.VITE_ENV === "development"
      ? "http://localhost:3001/api"
      : "/api",
  withCredentials: true,
  timeout: 10000,
});
export default api;

During local development, the frontend and backend run on different ports, so the frontend needs the complete backend URL:

http://localhost:3001/api

However, in production, I use:

/api

Later, we will configure Nginx so that when the frontend sends a request to /api, Nginx forwards that request to our backend running internally on port 3001.


6. Set Up and Run the Backend

Before introducing Nginx, let's first run our backend normally and understand how we can access an application running on a specific port.

First, go to the backend directory:

cd backend

Install all the required dependencies:

npm install

Now create the .env file:

nano .env

Add all the required environment variables, save the file using Ctrl + O, press Enter, and exit using Ctrl + X.

Now start the backend:

npm run start

My Node.js backend is running on port 3001. At this point, the request flow is very simple:

User
  ↓
http://PUBLIC_IP:3001/api/auth/health
  ↓
DigitalOcean Droplet
  ↓
Node.js Backend running on port 3001

However, the server's firewall must allow incoming traffic on port 3001. For testing, I configured the DigitalOcean firewall to allow TCP traffic to the required port.

After allowing the port, I could access my backend health endpoint from the browser:

http://168.144.31.216:3001/api/auth/health

Now we have an important question: Do we really want users to access our application by writing :3001 in the URL?

This is where Nginx and reverse proxying come into the picture.

Before Nginx:

User → PUBLIC_IP:3001 → Backend

After Nginx:

User → PUBLIC_IP:80 → Nginx → localhost:3001 → Backend

7. Run the Backend Permanently Using PM2

Until now, we were running our backend using:

npm run start

This works, but if we close the SSH connection or stop the terminal process, our backend will also stop running. To solve this, we can use PM2, a process manager for Node.js applications.

Install PM2 globally:

npm install -g pm2

Now, instead of running the backend directly, start it with PM2. If your package.json has a start script:

pm2 start server.js --name shelflife-backend

Check whether the backend is running:

pm2 status

You can also check the application logs:

pm2 logs shelflife-backend

Now our backend continues running in the background even after we disconnect from the SSH session.


8. Before Moving Forward, Let’s Understand Nginx

Before configuring our production API URL, we first need to understand Nginx and why we are using it.

Nginx is a web server that can also work as a reverse proxy. In our setup, Nginx will become the main entry point for requests coming to our server.

Normally, applications may run on different ports:

Frontend → Port 5173
Backend  → Port 3001

Without Nginx, users might need to access an application using a URL with a port number:

http://SERVER_IP:3001

Standard HTTP traffic uses port 80, while HTTPS commonly uses port 443. By putting Nginx in front of our application, users can access the server without explicitly writing the application port:

http://SERVER_IP

Nginx listens on port 80 and decides where to send the incoming request.


What is a Proxy?

A forward proxy sits between a client and the internet.

For example, imagine a user cannot directly access a particular website from their network. They connect through a proxy server located somewhere else:

User → Proxy Server → Website

The website sees the request coming from the proxy server rather than directly from the user.


What is a Reverse Proxy?

A reverse proxy works on the server side. Instead of hiding or representing the client, it sits in front of one or more backend applications.

Without Nginx, a request might directly reach a particular application port:

User → SERVER_IP:3001 → Backend

After introducing Nginx:

                         ┌──→ Frontend
User → SERVER_IP:80 → Nginx
                         └──→ Backend on localhost:3001

Every public HTTP request first reaches Nginx on port 80. Nginx then decides where to send the request based on its configuration.

For example, later we can configure:

location /api/ {
    proxy_pass http://localhost:3001;
}

Now, when our frontend makes a request to:

/api/auth/login

the request first reaches Nginx on port 80. Nginx matches /api/ and forwards the request internally to the backend running on port 3001.

So our request flow becomes:

Browser
   ↓
http://SERVER_IP/api
   ↓
Nginx :80
   ↓
Backend localhost:3001

This is the reason we changed our frontend production API URL from:

http://localhost:3001/api

to:

/api

Nginx does not replace /api in our JavaScript code. The browser sends the /api request to the same server, and Nginx receives and forwards it to the appropriate backend service.


Installing Nginx

First, update the package list:

sudo apt update

Then install Nginx:

sudo apt install nginx

After installation, Nginx usually starts automatically and listens on port 80, which is the default port for HTTP traffic.

Before accessing Nginx from the browser, go to your DigitalOcean firewall/security settings and allow inbound HTTP traffic on port 80.

Now open your Droplet's public IP in the browser:

http://YOUR_SERVER_PUBLIC_IP

Since HTTP uses port 80 by default, you don't need to explicitly write :80 in the URL.

http://YOUR_SERVER_PUBLIC_IP
              ↓
         Port 80
              ↓
            Nginx

9. Configure Nginx as a Reverse Proxy

Now I tried accessing my backend without explicitly writing port 3001:

http://168.144.31.216/api/auth/health

But I got a 404 Not Found error.

Why? Because the request was now going to port 80, where Nginx was listening. However, Nginx did not yet know that requests starting with /api/ should be forwarded to our backend running on port 3001.

Let's configure Nginx.

Open the default Nginx configuration:

sudo nano /etc/nginx/sites-available/default

Inside the server block, add:

location /api/ {
    proxy_pass http://localhost:3001;
}

Our request flow will now become:

User requests:
http://168.144.31.216/api/auth/health
              ↓
         Nginx :80
              ↓
      Matches location /api/
              ↓
    Forwards the same path to
localhost:3001/api/auth/health
              ↓
        Node.js Backend

After changing the Nginx configuration, first check that there are no syntax errors:

sudo nginx -t

Then reload Nginx so that it starts using the updated configuration:

sudo systemctl reload nginx

Now visit the endpoint again:

http://168.144.31.216/api/auth/health

This time, Nginx should forward the request to our backend running on port 3001.


10. Serve the Frontend Using Nginx

Our backend is now accessible through Nginx using /api. Next, let's serve our frontend using Nginx as well.

Earlier, when we ran:

npm run build

Vite created a dist folder containing the production-ready frontend files.

First, remove the default Nginx files from /var/www/html:

sudo rm -rf /var/www/html/*

Now, from your project's frontend directory, copy everything inside the dist folder to /var/www/html:

sudo cp -r dist/* /var/www/html/

You can verify the copied files:Now configure Nginx to serve the frontend while continuing to proxy API requests:

server {
    listen 80;
    server_name 168.144.31.216;

    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://localhost:3001;
    }
}
ls /var/www/html

Here, location / serves our React frontend, while location /api/ forwards API requests to the Node.js backend.

The final request flow is:

User → http://168.144.31.216
              ↓
            Nginx
           ↙     ↘
        /           /api/
        ↓             ↓
React Frontend    Backend :3001
/var/www/html     PM2 + Node.js

After updating the configuration, test and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Now opening your public IP in the browser should load your frontend, while frontend requests to /api are automatically forwarded by Nginx to your backend.


Conclusion

And that's it! We successfully deployed our full-stack application on a DigitalOcean Virtual Machine.

Throughout this deployment journey, I learned how different pieces of a production setup work together—from creating a VM and connecting to it securely using SSH, to installing Node.js, running the backend with PM2, and using Nginx to serve the frontend and forward API requests.

Our final architecture looks like this:

This was my first step toward understanding real-world application deployment, and I hope this guide helps anyone who is also moving their project from localhost to a cloud server.

Thanks for reading! 🚀

G

I guess u leaked your ssh key