Skip to content
sang.id.vn
Go back

Auto-Deploy JavaScript Projects with GitHub Workflow

Tiếng Việt

Push to master, wait 2 minutes, site is live. That was the goal. Getting there took me through SSH-based deploys, CI-built artifacts, caching tricks, and a few workflow runs that accidentally killed my hosting. Here’s the evolution of my GitHub Workflow for deploying JavaScript projects.

My first approach was simple: SSH into the server, git pull && yarn. That gave me this workflow:

name: Deploy

on:
  push:
    branches: [master]

jobs:
  Deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Main workflow
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          password: ${{ secrets.SSH_PASSWORD }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            cd ${{ secrets.PATH_TO_PROJECT }}
            git pull
            yarn install
            yarn build
            # ...

After testing, I noticed problems:

So I revised it:

name: Deploy

on:
  push:
    branches: [master]
  workflow_dispatch: # Allow manual trigger on GitHub

concurrency:
  # Group name for instances. Instances in the same group
  # won't run simultaneously. Here group = workflow name + branch name,
  # so each branch only has one instance running at a time
  group: ${{ github.workflow }}-${{ github.ref }}
  # If multiple instances are created in the same group,
  # cancel older ones and run the latest
  cancel-in-progress: true
# ...

Looks good, but another issue appeared. When the workflow runs yarn install && yarn build, all websites on the hosting become inaccessible because the command maxes out available processes and pushes RAM near the limit.

So I thought: why not move the build step to the workflow environment and just upload the built code to the hosting? I revised the workflow:

name: CD

# ...

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    env:
      ZIP_FILE: ${{ github.event.repository.name }}-${{ github.ref_name }}.zip
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Run install
        run: yarn install --frozen-lockfile --silent

      - name: Build code
        run: yarn build
        env:
          NODE_ENV: production

      - name: Compress
        run: rm -rf node_modules && zip -qr $ZIP_FILE .

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh/
          echo "$SSH_KEY" > ~/.ssh/server
          chmod 600 ~/.ssh/server
          cat > ~/.ssh/config <<END
          Host server
            HostName $SSH_HOST
            User $SSH_USERNAME
            IdentityFile ~/.ssh/server
            PubkeyAuthentication yes
            ChallengeResponseAuthentication no
            PasswordAuthentication no
            StrictHostKeyChecking no
          END
        env:
          SSH_USERNAME: ${{ secrets.SSH_USERNAME }}
          SSH_KEY: ${{ secrets.SSH_KEY }}
          SSH_HOST: ${{ secrets.SSH_HOST }}

      - name: Upload
        run: scp $ZIP_FILE ${{ secrets.SSH_USERNAME }}@server:~

      - name: Migrate
        run: # ...

Now the hosting doesn’t need to handle the build. No need for Git on the hosting either — code gets uploaded via SSH.

But after more testing, I noticed each workflow instance re-downloads all dependencies from scratch during yarn install. This increases wait time. So I added caching to reuse previously downloaded packages.

I also forgot about the Node.js version. To match the hosting’s Node.js version and avoid unnecessary errors, I created an .nvmrc file in the project to define the target version. This file is used by nvm (Node Version Manager).

After adding caching and Node.js version management:

name: CD

# ...

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    env:
      ZIP_FILE: ${{ github.event.repository.name }}-${{ github.ref_name }}.zip
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      # nvm
      - uses: actions/setup-node@v3
        with:
          node-version-file: ".nvmrc"

      # Get yarn cache directory path in workflow instance
      - name: Get yarn cache directory path
        id: yarn-cache-dir-path
        run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT

      # Caching
      - uses: actions/cache@v3
        id: yarn-cache
        with:
          # Yarn cache path from above
          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
          # Key for cache versioning. Creates new cache when yarn.lock changes.
          # Works like Redis key management
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          # Fallback key prefix if the above key doesn't exist
          restore-keys: |
            ${{ runner.os }}-yarn-

      # This action runs yarn commands. Also supports caching and
      # Node.js version management similar to the above actions.
      # Can be used as a replacement.
      - name: Run install
        uses: borales/actions-yarn@v4
        with:
          cmd: install --frozen-lockfile --silent

      - name: Build code
        run: yarn build
        env:
          NODE_ENV: production

      - name: Compress
        run: rm -rf node_modules && zip -qr $ZIP_FILE .

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh/
          echo "$SSH_KEY" > ~/.ssh/server
          chmod 600 ~/.ssh/server
          cat > ~/.ssh/config <<END
          Host server
            HostName $SSH_HOST
            User $SSH_USERNAME
            IdentityFile ~/.ssh/server
            PubkeyAuthentication yes
            ChallengeResponseAuthentication no
            PasswordAuthentication no
            StrictHostKeyChecking no
          END
        env:
          SSH_USERNAME: ${{ secrets.SSH_USERNAME }}
          SSH_KEY: ${{ secrets.SSH_KEY }}
          SSH_HOST: ${{ secrets.SSH_HOST }}

      - name: Upload
        run: scp $ZIP_FILE ${{ secrets.SSH_USERNAME }}@server:~

      - name: Migrate
        run: # ...

Result after adding caching: average workflow time dropped from 2 minutes 31 seconds to… 2 minutes 16 seconds. Not groundbreaking, but every second counts when you’re iterating fast.

I later switched from Strapi to Laravel and rewrote the workflow from scratch. But the core patterns stayed the same: build on CI (not on the server), upload artifacts, and restart. If you’re setting up auto-deploy for the first time, start with the SSH approach and evolve from there as you hit real problems.


Share this post on:

Previous Post
Thesis Report Formatting in Word — Part 2: Table of Contents & Page Numbers
Next Post
Thesis Report Formatting in Word — Part 1: Basic Setup