CI/CD with GitHub Actions

/ Article / Satrio

The Problem: Manual Deployment is Annoying

Every time I wanted to update my personal blog, I had to go through a tedious process:

  1. Build the Docker image on my local machine.
  2. Send the image to my VPS.
  3. SSH into my server.
  4. Stop the running Docker container.
  5. Remove the old container.
  6. Start a new container with the updated image.

Here’s what my manual deployment looked like:

# on my local machine
docker build -t blog:linux --platform linux/amd64 .
docker save blog:linux | gzip | ssh {user@my-vps} docker load

# ssh to my vps.
ssh {user@my-vps}

# on my vps (remote), i use docker compose for my services
docker compose stop blog
docker compose rm blog
docker compose up -d blog
docker ps -a     # check running container
# optional
docker rmi {ID of old image}

This was fine at first, but after doing it multiple times, it became frustrating. I needed automation.

Enter GitHub Actions

I had heard about GitHub Actions before but never actually used it. Since my blog’s code is already hosted on GitHub, using GitHub Actions for automation made perfect sense.

Writing My First Deployment Workflow

I started by creating a .github/workflows/github-ci.yml file to automate the deployment process:

name: Deploy Next.js Blog

on:
  push:
    branches:
      - main

jobs:
  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout the repository
      - name: Checkout code
        uses: actions/checkout@v4

      # Step 2: Set up Docker
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      # Step 3: Build the Docker image
      - name: Build Docker image
        run: |
          docker build -t blog:linux .

      - name: Configure SSH
        env:
          VPS_USER: ${{ secrets.VPS_USER }} # Set up secrets for your VPS login
          VPS_HOST: ${{ secrets.VPS_HOST }}
          VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} # Set up an SSH key for authentication
        run: |
          mkdir -p ~/.ssh/
          echo "$VPS_SSH_KEY" > ~/.ssh/vps.key
          chmod 600 ~/.ssh/vps.key
          cat >>~/.ssh/config <<END
          Host vps
            HostName $VPS_HOST
            User $VPS_USER
            IdentityFile ~/.ssh/vps.key
            StrictHostKeyChecking no
          END

      # Step 4: Compress the Docker image and send it over SSH to VPS
      - name: Push Docker image to VPS
        run: |
          # Save and compress the Docker image
          docker save blog:linux | gzip | ssh vps docker load

      # Step 5: Deploy on VPS using SSH
      - name: Deploy to VPS
        run: |
          ssh vps << 'EOF'
            cd ~/projects/
            docker compose stop blog
            docker compose rm blog -f
            docker compose up -d blog
          EOF

After committing this workflow, my deployment was fully automated! Every time I pushed to the main branch, my blog would deploy automatically. No more manual SSH-ing!

Adding CI to the Pipeline

After setting up automated deployment, I realized that I was only doing CD (Continuous Deployment). To have a proper CI/CD pipeline, I needed to add Continuous Integration (CI) steps before deploying.

What I Added for CI:

  1. Install dependencies – Ensure the app builds with correct dependencies.
  2. Linting – Catch errors and enforce code consistency.
  3. Build – Verify the project compiles before deployment.

CI Workflow

I update my previous GitHub Action workflow for additional CI. this is the final workflow .github/workflows/github-ci.yml:

name: CI/CD for Next.js Blog

on:
  push:
    branches:
      - main # Trigger only on pushes to the main branch
  pull_request: # Run CI on PRs as well
    branches:
      - main

jobs:
  ci:
    name: Continuous Integration
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout the repository
      - name: Checkout code
        uses: actions/checkout@v4

      # Step 2: Set up Node.js and pnpm
      - name: Set up Node.js and pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 10 # Set pnpm version (adjust if needed)

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"

      # Step 3: Install dependencies
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # Step 4: Run ESLint (Code Linter)
      - name: Lint code
        run: pnpm run lint

      # Step 5: Build Next.js App
      - name: Build the app
        run: pnpm run build

  deploy:
    name: Deploy to VPS
    needs: ci # Ensure CI passes before deploying
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout the repository
      - name: Checkout code
        uses: actions/checkout@v4

      # Step 2: Set up Docker
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      # Step 3: Build the Docker image
      - name: Build Docker image
        run: |
          docker build -t blog:linux .

      - name: Configure SSH
        env:
          VPS_USER: ${{ secrets.VPS_USER }} # Set up secrets for your VPS login
          VPS_HOST: ${{ secrets.VPS_HOST }}
          VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} # Set up an SSH key for authentication
        run: |
          mkdir -p ~/.ssh/
          echo "$VPS_SSH_KEY" > ~/.ssh/vps.key
          chmod 600 ~/.ssh/vps.key
          cat >>~/.ssh/config <<END
          Host vps
            HostName $VPS_HOST
            User $VPS_USER
            IdentityFile ~/.ssh/vps.key
            StrictHostKeyChecking no
          END

      # Step 4: Compress the Docker image and send it over SSH to VPS
      - name: Push Docker image to VPS
        run: |
          # Save and compress the Docker image
          docker save blog:linux | gzip | ssh vps docker load

      # Step 5: Deploy on VPS using SSH
      - name: Deploy to VPS
        run: |
          ssh vps << 'EOF'
            cd ~/projects/
            docker compose stop blog
            docker compose rm blog -f
            docker compose up -d blog
          EOF

Now, every time I push code or open a PR, GitHub Actions:

If any of these steps fail, the deployment won’t proceed.

Conclusion: From Manual Process to Automation

By integrating GitHub Actions, I went from manually deploying my blog to having a fully automated CI/CD pipeline. Now:

✅ Every push to main runs CI checks. ✅ If CI passes, my blog is automatically deployed. ✅ No more SSH-ing and running commands manually.

If you’re deploying a project manually, I highly recommend automating it with GitHub Actions. It saves time, reduces mistakes, and makes your workflow so much smoother.