Setup blog

5/3/2025

This is the sharing process, that I do the blog setup, will detail as much as possible in writing.

Prerequisite

Firstly, you should order a VPS, you can use any trusted vps out there like digital ocean or aws... I'm having a vps running on my country server (Viet Nam). Once you have your server running on the vps, make sure these libs/packages are installed:

  • Docker
  • Docker compose
  • Git
  • A web blog repository

Why?

Why Docker/Docker compose?

With Docker, the reason i'm using the Docker is because it help me to move the configuration easily whenever i want to change or re-setup the vps. All I need to do is writing the script that help me to automate the docker setup for the website, so I can bring that configuration anywhere for later use without messing the environment.

With Docker compose, due to the multiple apps/projects that need for https/ssl and also the environment request setup, we will configure multiple services with docker compose.

Why Git?

In this guide, we'll use the github actions to automate the cloning and building the project, so Git here is for the use of the cloning.

Make sure all the packages are installed by running the command:

Why repository?

Simple, it is a thing you want it over the internet, in my case, it is a Next.Js project due to its built-in support for SEO and other useful features that help me to set up easily

git --version
docker compose version
docker --v

The expected output should be:

git version 2.43.0
Docker Compose version v2.35.1
Docker version 28.1.1, build 4eba377

The version in this blog might be different as the time I'm writing.

Actions

Suppose, your project repo will be something like this:

|___ blog
|   |___ src
|   |___ package.json
|   |___ tsconfig.json
|   |___ app
|       |___ page.tsx
|   |___ ...

Set up the Docker file

Firstly, we need to set up a Dockerfile which is helpful for creating the images and containers for the project.

# ---------- Build Stage ----------
FROM node:18-alpine AS builder
# Install pnpm
RUN npm install -g pnpm
# Set working directory
WORKDIR /app
# Copy dependency definitions
COPY package.json pnpm-lock.yaml ./
# Install all dependencies (including dev)
RUN pnpm install
# Copy all source code
COPY . .
# Build the Next.js app
RUN pnpm build
# ---------- Production Stage ----------
FROM node:18-alpine AS runner
# Install pnpm and pm2 globally
RUN npm install -g pnpm pm2
# Set working directory
WORKDIR /app
# Copy only production dependencies
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod
# Copy built app and necessary runtime files from builder
COPY --from=builder /app/.next ./.next
# COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/package.json ./package.json
# Expose port 9000
EXPOSE 9000
# Set the environment variable for the port
ENV PORT=9000
# Start the app using pm2-runtime
CMD ["pm2-runtime", "start", "pnpm", "--", "start"]

Put this .Dockerfile to the blog directory:

|___ blog
|   |___ src
|   |___ package.json
|   |___ tsconfig.json
|   |___ app
|       |___ page.tsx
|   |___ ...
|   Dockerfile <-- this

If you haven't well-known with the Docker syntax, you can take a look at this link to have a better understanding the syntax sugar, my Dockerfile's content is just a set of simple commands that automate the build and run the blog app.

Set up the Docker compose

The docker compose is a set of scripts that support to run the Docker scripts creating and managing multiple services for the Docker, you can see it as a set of the Docker containers, instead of creating and managing multiple Dockerfile files for multiple projects/libs, you can use the docker compose as the superset one in single script.

Put this docker-compose.yml to the blog directory:

|___ blog
|   |___ src
|   |___ package.json
|   |___ tsconfig.json
|   |___ app
|       |___ page.tsx
|   |___ ...
|   Dockerfile
|   docker-compose.yml <--this

And update the file to:

version: '3.8'

services:
  app:
    build:
      context: . # Build the Next.js app from the current directory
    container_name: nextjs-app # Name of the container
    expose:
      - '9000' # Exposes port 9000 to the other containers, but does not expose it to the host
    networks:
      - my_network # Ensures both containers are on the same Docker network (can be defined later)

  nginx:
    image: nginx:alpine # Using the official Nginx image
    container_name: nginx # Name of the container
    ports:
      - '80:80' # Exposes HTTP (port 80) to the host machine
      - '443:443' # Exposes HTTPS (port 443) to the host machine
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/ssl.conf:/etc/nginx/conf.d/ssl.conf:ro # this one is mounted only after certs exist
      - letsencrypt:/etc/letsencrypt
      - webroot:/var/www/html
    depends_on:
      - app # Ensures that the Nginx container starts after the app container
    networks:
      - my_network # Ensures both containers are on the same Docker network (can be defined later)

  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - letsencrypt:/etc/letsencrypt
      - webroot:/var/www/html
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet && nginx -s reload; sleep 12h; done;'"
    depends_on:
      - nginx
    networks:
      - my_network

volumes:
  letsencrypt:
  webroot:

networks:
  my_network: # This ensures that both the app and Nginx containers are connected to the same network
    driver: bridge

Be careful with spacing, the docker compose treats the spacing with respect ident that represents for each level of the script.

Talk briefly about the docker compose file, we have three services:

  • app

This is the service that run our main blog app, which is running on 9000 as the configured (you can choose any port you like), the context . here is indicating to the Dockerfile above which will run the Dockerfile in the container named nextjs-app

  • nginx

We use the nginx:alpine image from the Docker hub to install the container named nginx which will be mainly using for reversed proxy, which will firstly receive any request as the first stage before getting into to the main app. We'll mount couple of files and folder to the shared host locations (vps in this case). This nginx depends on the app to be running first. And both of them will use the same network named my_network for traffic routing and networking firewall rules.

  • certbot This one mainly uses to creating/extending the SSL certificate files, which will ensure our request is secured with HTTPS/TLS. The entry point is a script that run every 12 hours to renew the certificate to prevent the expiration issue. Also because this service is mainly used with nginx for the the traffic, so it is required the nginx to be running before them.

Basically, we'll later generate the certificate at the first time, and map the files for later use to connect with nginx. I also give explanation to each line of the script to make you more understand about the process.

Set up the Nginx

We'll configure the nginx files inside of the nginx folder which will basically create two conf files that configure both http and routing to the secured https connection.

  • Http connection

    Create the default.conf which is used for the default configuration setting one, it will indicate the servername and also the certificate location.

    Put this default.conf to the blog/nginx directory:

    |___ blog
    |   |___ src
    |   |___ package.json
    |   |___ tsconfig.json
    |   |___ app
    |       |___ page.tsx
    |   |___ ...
    |   |___ nginx
    |        |___ default.conf <--this
    |   Dockerfile
    |   docker-compose.yml
    

    And the content:

    server {
        listen 80;
        server_name your_domain www.your_domain;
    
        # Serve Certbot verification files
        location /.well-known/acme-challenge/ {
            root /var/www/html;
            try_files $uri $uri/ =404;
        }
    
        # Redirect all other HTTP traffic to HTTPS (after certificates are obtained)
        location / {
            return 301 https://$host$request_uri;
        }
    }
    

    The your_domain should be your real domain, in my case, I purchased a Namecheap domain. Don't forget to set up the Namecheap domain your real public VPS IP address. Follow the link for configuration.

  • Https connection: Create the ssl.conf which is used for the secured configuration setting, it will indicate the servername and also the certificate location.

    Put this ssl.conf to the blog/nginx directory:

    |___ blog
    |   |___ src
    |   |___ package.json
    |   |___ tsconfig.json
    |   |___ app
    |       |___ page.tsx
    |   |___ ...
    |   |___ nginx
    |        |___ default.conf
    |        |___ ssl.conf <--this
    |   Dockerfile
    |   docker-compose.yml
    

    And the content:

    # ssl.conf (only copy this after certs are available)
    server {
      listen 443 ssl;
      server_name your_domain www.your_domain;
    
      ssl_certificate /etc/letsencrypt/live/your_domain/fullchain.pem;
      ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;
    
      ssl_protocols TLSv1.2 TLSv1.3;
      ssl_ciphers HIGH:!aNULL:!MD5;
      ssl_prefer_server_ciphers on;
    
      location / {
          proxy_pass http://app:9000;
          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;
      }
    }
    

Setup Github workflows

My git client is Github, so I'm using the Github workflows actions for auto deploying the process. You can do any other cloud actions, or more advanced with Jenkins, in my case, I simplized the process with Github workflows.

Create the docker-build.yml and put it inside of the .github/workflows folder:

|___ blog
|   |__ .github/workflows
|       |__ docker-build.yml <--this
|   |___ src
|   |___ package.json
|   |___ tsconfig.json
|   |___ app
|       |___ page.tsx
|   |___ ...
|   |___ nginx
|        |___ default.conf
|        |___ ssl.conf
|   Dockerfile
|   docker-compose.yml

And update the content to:

name: Deploy to VPS

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v0.1.5
        with:
          host: ${{ secrets.VPS_IP }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: 22
          script: |
            PROJECT_DIR="/home/${{ secrets.VPS_USERNAME }}/projects/blog"
            REPO_URL="your_repo_link"
            DOMAIN="your_domain"
            EMAIL="your_email"

            echo "=== Cleaning up existing project ==="
            rm -rf "$PROJECT_DIR"

            echo "=== Cloning latest code ==="
            git clone "$REPO_URL" "$PROJECT_DIR"
            cd "$PROJECT_DIR"

            echo "=== Starting Nginx and App (HTTP only) ==="
            docker compose down || true
            docker compose up --build -d nginx app

            echo "=== Waiting for Nginx to boot ==="
            sleep 5

            echo "=== Generating SSL certificates via Certbot ==="
            docker compose run --rm --entrypoint "" certbot certbot certonly \
              --webroot -w /var/www/html \
              -d "$DOMAIN" -d "www.$DOMAIN" \
              --email "$EMAIL" \
              --agree-tos --no-eff-email \
              --force-renewal

            echo "=== Reloading Nginx with HTTPS ==="
            docker compose exec -T nginx nginx -s reload || true

            echo "=== Restarting all services to finalize ==="
            docker compose restart

Basically, the simple things are done in the github action, all they need to do is about cloning, and setting up the project with docker compose as I said before. By printing out the step by step comments, hope you understand clearly about the process. Don't forget to update your own credentials information.

Stay tune until I'm up to a new blog.