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

### 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.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/9a740ebe-06b1-4bbb-8b8d-2d0327b31ad9.png align="center")

* * *

**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.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/ed73a15e-3840-418c-b809-dec54ccda343.png align="center")

* * *

**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.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/7d8af8d2-05cd-4a3a-a286-fca91e32c3c4.png align="center")

* * *

**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`](http://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_ed25519` → **Private key** (never share this)
    
*   `id_`[`ed25519.pub`](http://ed25519.pub) → **Public key** (safe to add to your server)
    

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

`Get-Content ~/.ssh/id_`[`ed25519.pub`](http://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.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/22c1c4a8-bd0c-4e55-b788-091108d75073.png align="center")

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/3e8f7a46-b89c-42e9-842b-99b6a8d2d38d.png align="center")

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/1c2216cb-5fc4-4595-b20a-461cc5ce7eae.png align="center")

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/ce76b5c9-e9e6-4022-85e6-badaa6cf73d6.png align="center")

* * *

**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.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/90e4675d-2a2d-41a1-aba6-b44116c3b374.png align="center")

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/cfdc70ed-adcc-43e0-81c4-a55b2e715ab1.png align="center")

* * *

### 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**.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/64531730-7f3b-40f0-9c6e-379adadb28ff.png align="center")

* * *

### 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`.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/e6233485-cc81-4bf4-851d-1440183ec27f.png align="center")

* * *

### 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](https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-20-04?utm_source=chatgpt.com).

First, install NVM:

```js
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:

```js
source ~/.bashrc
```

Now install the required Node.js version:

```js
nvm install v24.13.0
```

Finally, verify that Node.js was installed successfully:

```hs
node -v
```

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

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/303db7c0-1094-4c42-9ae3-619584118e77.png align="center")

* * *

### 4\. Clone the Project Repository

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

Run:

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

After cloning, move inside the project directory:

```js
cd ShelfLife-Household-Inventory-Tracker
```

Now you can check the project files and folders using:

```js
ls
```

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

For example:

```js
cd backend
```

or:

```js
cd frontend
```

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

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/8033857a-7a90-40f2-9f2a-928ed8b6c2a7.png align="center")

* * *

### 5\. Install Frontend Dependencies and Create the Production Build

Now, let's move into the frontend folder:

```js
cd frontend
```

Install all the required dependencies:

```js
npm install
```

Before creating the production build, check the environment file:

```js
cat .env
```

In development, my environment variable was:

```plaintext
VITE_ENV=development
```

To edit:

```js
nano .env
```

Change it to:

```js
VITE_ENV=production
```

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

```javascript
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:

```js
http://localhost:3001/api
```

However, in production, I use:

```js
/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`.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/5c996f89-efa3-4ea7-8896-bd47f54610fd.png align="center")

* * *

### 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:

```plaintext
cd backend
```

Install all the required dependencies:

```plaintext
npm install
```

Now create the `.env` file:

```plaintext
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:

```plaintext
npm run start
```

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

```typescript
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:

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

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/5e1985a3-c942-44b4-b15e-a1b5ae86258d.png align="center")

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.

```plaintext
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:

```plaintext
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:

```plaintext
npm install -g pm2
```

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

```js
pm2 start server.js --name shelflife-backend
```

Check whether the backend is running:

```plaintext
pm2 status
```

You can also check the application logs:

```plaintext
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:

```plaintext
Frontend → Port 5173
Backend  → Port 3001
```

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

```plaintext
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:

```plaintext
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:

```plaintext
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:

```plaintext
User → SERVER_IP:3001 → Backend
```

After introducing Nginx:

```plaintext
                         ┌──→ 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:

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

Now, when our frontend makes a request to:

```javascript
/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:

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

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

```plaintext
http://localhost:3001/api
```

to:

```plaintext
/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:

```plaintext
sudo apt update
```

Then install Nginx:

```plaintext
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:

```plaintext
http://YOUR_SERVER_PUBLIC_IP
```

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

```plaintext
http://YOUR_SERVER_PUBLIC_IP
              ↓
         Port 80
              ↓
            Nginx
```

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/dfdfeb6f-4f88-4a75-8310-61690b62e05e.png align="center")

* * *

### 9\. Configure Nginx as a Reverse Proxy

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

```plaintext
http://168.144.31.216/api/auth/health
```

But I got a **404 Not Found** error.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/d541c6cc-5e96-417a-816d-0c7d88c43f8d.png align="center")

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:

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

Inside the `server` block, add:

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

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/2ed00cd5-c333-457d-8a56-7bbf5246c357.png align="center")

Our request flow will now become:

```plaintext
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:

```plaintext
sudo nginx -t
```

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

```plaintext
sudo systemctl reload nginx
```

Now visit the endpoint again:

```plaintext
http://168.144.31.216/api/auth/health
```

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

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/acb1ad36-12c0-4003-bc9a-e7fb159aa144.gif align="center")

* * *

### 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:

```plaintext
npm run build
```

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

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

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

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

```plaintext
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:

```javascript
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;
    }
}
```

```plaintext
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:

```plaintext
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:

```plaintext
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.

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/34c26d35-3f95-4cf8-9b0a-acdee7fc25c4.png align="center")

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/e388b9f7-9ed0-49b1-a921-484e40e83528.png align="center")

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/36873917-2457-40ef-bad8-e3aa622aa550.gif align="center")

* * *

### 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:

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/b93d091a-5d75-45bf-8121-aa6d68b561a1.png align="center")

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! 🚀
