DEV Community

Joseph01
Joseph01

Posted on

Mastering VPS For Frontend Engineer - Part 3

Here we're the final round, after we've put our web applications on VPS, now it's time to improve the Developer Experience by adding Docker and CI/CD github actions, so we will get rid from VPS and automate the process 😃.

Checkout previous parts if you're new 👉 Part 2 Part 1

So here is what we'll learn this time

1. Adding Firewall

2. Adding Docker and Docker Compose

3. Automate Deployment with Github CI/CD action

4. Adding Load Balancer


Adding Firewall

Adding firewall is super easy but if you forget this stuff you really face critial security issues and opening doors for attackers, using UFW let us close all the unnecessary ports that are already open by some reasons.

We'll make sure to only open certain Ports are open, including SSH Port, HTTP and HTTPS connections.

Let's install ufw (Uncomplicated Firewall) a package for enabling and disabling access to ports.

sudo apt update
sudo apt install ufw
Enter fullscreen mode Exit fullscreen mode

Now let's enable it and open the ports that we need.

sudo ufw enable
Enter fullscreen mode Exit fullscreen mode

Then enable what we need (SSH, HTTP, HTTPS)

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
Enter fullscreen mode Exit fullscreen mode

Now lets check all the ports by

sudo ufw status
Enter fullscreen mode Exit fullscreen mode

See all the opened port, and if you've backend you can open a port for that as well

sudo ufw allow 3002/tcp
Enter fullscreen mode Exit fullscreen mode

This we will open 3002/tcp port for backend requests.

Docker & Docker Compose

With Docker, your app will run the same way everywhere, on your computer, your teammate’s computer, or even on a cloud server.

The core principle of Docker is straightforward: we create a Dockerfile that contains all the information needed to run our project. For example, if we have a Next.js app, we specify Node.js as the environment and use npm (or yarn) to run it. In the Dockerfile, we write these instructions step by step.

When we build the Dockerfile, Docker creates something called an image, a snapshot that includes our app, its dependencies, and the environment it needs.

From this image, we can launch a container. A container is a running instance of our app, isolated from everything else, and usually exposed through specific ports (for example, port 3000 for a Next.js app).

Now let's instead using PM2 we will use Docker to build and run our NextJS app.

One more thing before we start with Docker: we’ll be using Docker Compose. Docker Compose is super helpful when you have multiple services or more than one container. Instead of writing multiple docker run commands, you can simply define your services in a docker-compose.yml file and let Compose handle running, managing, and connecting the containers for you.

Installing Docker and Docker Compose

We should setup repository, bellow we can get it for Ubuntu,


sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
Enter fullscreen mode Exit fullscreen mode

Then add the plugin

sudo apt-get update
sudo apt-get install docker-compose-plugin
Enter fullscreen mode Exit fullscreen mode

And verify the installation

docker compose version
Enter fullscreen mode Exit fullscreen mode

Writing Dockerfile

We've to got the our project directory which was under /var/www/frontend/ and create our docker file instruction.

run

cd /var/www/frontend/
Enter fullscreen mode Exit fullscreen mode

create Dockerfile

vi Dockerfile
Enter fullscreen mode Exit fullscreen mode

and fill it with the content, in our case we use nodejs 22 image, and some instruction to build and run our production app.

You can copy paste this or maybe you want to use another version of NodeJS based on your needs

FROM node:22

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

Now, let’s break it down:

  • The second line, WORKDIR /app, sets the working directory inside the Docker container. All subsequent commands run from /app.
  • COPY package*.json ./ copies the package.json and package-lock.json files into the container.
  • RUN npm install installs all the dependencies listed in those files.
  • COPY . . copies the rest of your source code into /app.
  • RUN npm run build builds the Next.js app.
  • EXPOSE 3000 opens port 3000 inside the container, because Next.js runs on port 3000 by default when started with npm start.
  • CMD ["npm", "start"] tells Docker how to start the app when the container runs.

As you can see, it’s a straightforward set of instructions. Once this Dockerfile is used to build an image, the image becomes a
ready-to-use environment that can spawn containers anytime, each running your Next.js app in an isolated environment.

Lastly exit and save the file, click Esc and type :wq to save and quite the Dockerfile.

Build Docker Image

Now with our Dockerfile in place we will create an Image from those instructions we put in Dockerfile, before using Docker Compose we will use pure Docker commands, so you see what headache will Docker Compose move away.

Build image by running this command in same directory of our Dockerfile

sudo docker build -t myfrontend:latest .
Enter fullscreen mode Exit fullscreen mode

-t myfrontend → gives the image a name (myfrontend) and tag (latest).

. → means current directory.

Now after it builds successfully, let's run reall container, but first make sure you stop it from pm2 if you running with pm2 with

pm2 stop frontend
Enter fullscreen mode Exit fullscreen mode

Run docker container (container is basically an instance running from the image):

sudo docker run -d -p 3000:3000 --name myfrontend_container myfrontend:latest
Enter fullscreen mode Exit fullscreen mode

With this the docker is running in the background (by using -d mode) and we mapped into port 3000 from our host to 3000 port on container, and we give it a name (with --name) myfrontend_container lastly we should specify the image name as well (the one we just created above) myfrontend:latest.

Now again your app is live on http://localhost:3000, which is we already mapped this to our main domain from nginx config of our NextJS app in Part 1.

Check Containers

You can see and check your running container with

sudo docker ps
Enter fullscreen mode Exit fullscreen mode

This will show all your running containers, in our case we've only one case, if you've more you can add -a at end to list all running and stopped containers.

Now to stop it easily run

sudo docker stop myfrontend_container
Enter fullscreen mode Exit fullscreen mode

This will stop our frontend app, to start it again run

sudo docker start myfrontend_container
Enter fullscreen mode Exit fullscreen mode

now if you want to remove it, lets say you've lots of containers you want to remove some of unused ones, to free up some memory spaces, you can run

sudo docker rm oldfrontend_container
Enter fullscreen mode Exit fullscreen mode

or if you want to even remove an image you can run this

sudo docker rmi oldfrontend:latest
Enter fullscreen mode Exit fullscreen mode

but bare in mind, you should first stop and remove all the containers that uses that image, cause all of them depending on it.

You can list the images you've with

sudo docker images
Enter fullscreen mode Exit fullscreen mode

You can also check the logs of your app, in case of having some issues or bugs,

sudo docker logs myfrontend_container
Enter fullscreen mode Exit fullscreen mode

just give a name of your containers and see the logs, that is it, powerfull yeah! I know, lets make it more powerfull by simplifying it with Docker Compose.

Using Docker Compose

Instead of starting containers manually with long docker run commands, you write all your app’s services (backend, frontend, database, etc.) in one YAML file (docker-compose.yml) and run them with a single command.

Lets write for our NextJS project and create docker file.

Create docker compose yaml file

vi docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

and put fill it with these content

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: frontend_app
    ports:
      - "3000:3000"
    restart: always
Enter fullscreen mode Exit fullscreen mode

In Docker Compose, everything runs inside a service (like “mini apps”), we've only one service called frontend, and we tell some information about building, like it's context, that tells Docker to build from the current folder (.), and we explicitly tell which one is the dockerfile (it also default to use Dockerfile) incase you're using different file name, and we give it a name frontend_app, as well as mapping host port 3000 → container port 3000, and to make Docker to restart incase of the container stops or your machine reboots.

Now here is fun part, let's easily run and make the docker up and running

sudo docker compose up -d
Enter fullscreen mode Exit fullscreen mode

-d means running the process in the background, and with this simple command we started the services.

To stop the service

sudo docker compose down
Enter fullscreen mode Exit fullscreen mode

if you check running docker you'll see that frontend_app is in the running list

sudo docker ps
Enter fullscreen mode Exit fullscreen mode

CI/CD Github actions

We currently have our app running on port 3000, but here’s the problem: every time you make a change or push an update, you’d have to manually access the server, rebuild Docker, and restart the container. That’s not only repetitive but also a poor Developer Experience (DX).

Wouldn’t it be great if your app could automatically update in production the moment you push code just like Vercel handles automatic deployments?

That’s where CI/CD (Continuous Integration / Continuous Delivery) with GitHub Actions comes in. As the name suggests, it continuously integrates your changes and delivers them to production. Every time you push code, GitHub Actions can automatically run tests, linting, builds, and even deploy updates to your server.

With CI/CD, you no longer need to log into your server, pull changes, and restart Docker manually, your workflow is automated, your app stays up to date, and you get a much smoother development and deployment process.

All of this is defined in YAML workflow files stored in .github/workflows/.

Now before writing the yaml file for auto deployment with github action we need a couple of thing.

Establishing SSH connection with github, so github can access to our VPS server and do its job, and apparently we will save sensitive information in our GitHub repository variables.

Sine we already did SSH connection process in first part, you can get instructions from there, in summary you need to do
1- Generate an SSH Key Pair
2- Add Public Key to VPS (to authorized_keys)
3- Add Secrets to GitHub (Now on Github same repository open Settings → Secrets and variables → Actions, add VPS_SSH_KEY, open the file id_ed25519 (private key) and paste the whole content (including -----BEGIN OPEN SSH PRIVATE KEY-----).

Beside this also we need to add two more sensitive information which your vps user and server IP address, add VPS_HOST (your IP), VPS_USER.

Now that we're ready lets create a deploy.yml file under .github/workflows/deploy.yml and paste that code in

name: Deploy to VPS

on:
  push:
    branches: [main] # runs on push/merge to main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository (for context/logs)
        uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script_stop: true
          script: |
            set -e
            cd /var/www/portfolio

            echo "🔄 Pulling latest code..."
            git fetch origin main
            git reset --hard origin/main

            echo "🛑 Stopping and removing old containers..."
            docker compose down --remove-orphans

            echo "🧹 Cleaning up unused images, volumes, and cache..."
            docker system prune -af --volumes

            echo "📦 Rebuilding Docker images from scratch..."
            docker compose build --no-cache

            echo "🚀 Starting containers..."
            docker compose up -d

            echo "✅ Deployment finished!"
            docker compose ps
Enter fullscreen mode Exit fullscreen mode

Ok, so this makes an automation that deploys your app to a VPS every time you push code to the main branch. Instead of manually logging into your server and rebuilding your app, GitHub does it for you. The workflow runs on a temporary Ubuntu machine, connects to your VPS securely over SSH (using the secrets you’ve added to GitHub), and then executes a series of commands on the server.

Once connected, it pulls the latest code from GitHub, stops and removes old Docker containers, cleans up unused images and volumes, rebuilds fresh Docker images, and finally starts the app again. In short, this setup makes sure your app is always up to date in production automatically, just by pushing code to GitHub.

Load Balancer

With Docker, we can easily run multiple instances of our application. For example, if your web app gets a lot of traffic and starts slowing down, we can create additional instances and distribute users across them. This way, no single instance gets overloaded, and your app stays fast and responsive.

The good news is that NGINX can handle this routing for us. All we need to do is spin up another Docker instance of our app and configure NGINX to send traffic to the new instance. This makes scaling your app simple and efficient.

So first update our docker compose to create two instance of our app instead of one

vi docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

Then simply add another app instance like on port 3001

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: frontend_app
    ports:
      - "3000:3000"
    restart: always

  frontend2:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3001:3000"
    restart: always
Enter fullscreen mode Exit fullscreen mode

And with this it will always create two instance of your app for port 3000 and 3001, and we can tell nginx by going to /etc/nginx/nginx.conf/ and create an upstream object locating both our apps.

upstream nextjsfrontend {

                server localhost:3000;
                server localhost:3001;
        }
Enter fullscreen mode Exit fullscreen mode

Lastly we only left to update the site config to point to our upstream we just create nextjsfrontend, move to your app server config under /etc/nginx/sites-available/frontend/ and make sure to update the location block to point to our nextjsfrontend

location / {
   proxy_pass http://nextjsfrontend;
}
Enter fullscreen mode Exit fullscreen mode

now just restart your nginx and here you go, you've got load balancer setup already.

I hope you enjoyed this tutorials I know it was long, and stay calm and enjoy learning, let me know if you've got any question.

Top comments (0)