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:
-
The workflow can’t be triggered manually on GitHub.
-
Multiple workflow instances can run concurrently, causing inconsistency since they operate directly on the hosting.
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.