Back to articles
Mar 23, 2025 - 8 MIN READ
Automated Deployment using Github Actions

Automated Deployment using Github Actions

Automate the workflow with GitHub Actions for quick, reliable releases.

Roslan Saidi

Roslan Saidi

GitHub Actions runners support three operating systems: MacOS, Linux, and Windows. Ensure that your server environment is compatible with these platforms. Assuming the application is already set up on a bare-metal server, you can proceed with the next steps. A bare-metal setup means that all components such as the web server, dependencies, and services are installed and configured manually on the operating system.

Our goal is to automate deployment so that every push or rollback is applied automatically, without the need to enter the server and run the commands manually.

Example environment and requirements:

  • Operating System: Linux
  • User: myuser (as sudoer, don't use root)
  • Host: Self hosted
  • Environment: Production or Staging

Example application setup

  • Architecture: Decoupled (client–server style)
  • Repositories: Two separate repos in GitHub (API & Frontend)
  • Workflows: Two workflows (one for API, one for Frontend)
  • Tech stack: Laravel PHP (API) and Nuxt.js (frontend)
  • Production app location: /srv/apps/<your-application>
  • Staging app location: /home/myuser/<your-application>

Here are the steps to build an automated deployment process.

Login to the server

Login the server as myuser.

Generate two separate SSH keys

In the home directory, create two separate SSH keys: one dedicated for the API and another for the frontend application.

# make sure you are in home directory ~ or /home/myuser
$ pwd
/home/myuser

#  create for API key
$ ssh-keygen -t ed25519 -C "api-deploy" -f id_api -N ""

# create for frontend key
$ ssh-keygen -t ed25519 -C "web-deploy" -f id_web -N ""

Now you will have two key pairs.

~/.ssh/id_api
~/.ssh/id_api.pub
~/.ssh/id_web
~/.ssh/id_web.pub

Create a configuration file

In the SSH folder, create a configuration file for the GitHub environments:

$ cd ~/.ssh
$ nano config

Add the following entries in the editor:

Host github.com-api
  HostName github.com
  User git
  IdentityFile /home/myuser/.ssh/id_api
  IdentitiesOnly yes

Host github.com-web
  HostName github.com
  User git
  IdentityFile /home/myuser/.ssh/id_web
  IdentitiesOnly yes

This configuration defines two SSH hosts:

  • github.com-api → used for API repositories.
  • github.com-web → used for frontend repositories.

Later, we will set the remote URLs of the repositories to use these hosts, making it easier to separate API and frontend access.

Set the remote URL

Assuming the application is organized into two main components API and Frontend, their directories would typically look like this:

  • The API service is located at: /srv/apps/<your-application>/api
  • The Frontend service is located at: /srv/apps/<your-application>/web
$ cd /srv/apps/<your-application>/api
$ git remote set-url origin git@github.com-api:<username>/<api-repository>.git

$ cd /srv/apps/<your-application>/web
$ git remote set-url origin git@github.com-web:<username>/<web-repository>.git

For configuring your remote URLs, you need to use the host aliases defined in ~/.ssh/config. This ensures Git knows which SSH key to use for each repository.

Set up a deploy key for each repository

Next, you need to copy both the API and Frontend SSH keys from the server, and add them to their respective repositories. Make sure each repository has the correct key pasted into its deploy key settings.

Copy the public keys

# api key
$ cat ~/.ssh/id_api.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICFS4/d7Atbkkasda9d39rewas0dasda90vdasdsad myuser@server

# frontend key
$ cat ~/.ssh/id_web.pub
ssh-ed25519 AAAAC3NzaC1lZDISEFSDFSF5AAAAICFS4/dGGGGAASDFVkasdaSDFSFSFs0dasdaSDFSF myuser@server

You need to paste the API key into the API repository, and the Frontend key into the Frontend repository.

  • Go to your repository > Settings > Deploy Keys or https://github.com/<username>/<repository>/settings/keys
  • Click Add deploy keys
  • Enter a title, paste the public key from your server.
  • Allow write access (optional)
  • Click Add key

Check the connection between server and Github

You need to test the SSH connections before proceeding to the next steps:

$ ssh -T github.com-api
$ ssh -T github.com-web

Expected output:

Hi myuser/api! You've successfully authenticated, but GitHub does not provide shell access.
Hi myuser/web! You've successfully authenticated, but GitHub does not provide shell access.

If the output is different, adjust the file permissions and try again.

$ chmod 600 ~/.ssh/id_api
$ chmod 600 ~/.ssh/id_web
$ chmod 600 ~/.ssh/known_hosts
$ chmod 700 ~/.ssh



Create and register Github runners

Next, You can create a runner for either a personal or an organization account.

For a personal repository:

  • Go to your repository > Settings > Actions > Runners
  • Click New self-hosted runner
  • Choose Linux as the operating system
  • Select the appropriate architecture (you can check your server architecture by running uname -a).
  • Follow the download and setup steps shown on the GitHub page.

For an organization repository

  • Go to your organization page > Settings > Actions > Runner groups
  • Click New runner group
  • Insert group name, select repositories (API and Web)
  • Click Create group
  • Once the group is created, click New runner
  • Choose Linux as the operating system
  • Select the appropriate architecture (you can check your server architecture by running uname -a).
  • Follow the download and setup steps shown on the GitHub page. (Be sure to download the latest runner version)

For example:

Download

# Go to home directory
$ cd ~

# Create a folder
$ mkdir actions-runner && cd actions-runner

# Download the latest runner package
$ curl -o actions-runner-osx-x64-2.328.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.328.0/actions-runner-osx-x64-2.328.0.tar.gz

# Optional: Validate the hash
$ echo "90c32dc6f292855339563148f3859dc5d402f237ecdf57010c841df3c8d12cc8  actions-runner-osx-x64-2.328.0.tar.gz" | shasum -a 256 -c

# Extract the installer
$ tar xzf ./actions-runner-osx-x64-2.328.0.tar.gz

Configure

# Create the runner and start the configuration experience
$ ./config.sh --url https://github.com/<username> --token ADDQRFFQIRRUMMLL6EW6T2EEEEEA4Y

When you execute the configuration, GitHub will ask you these during runner setup:

1. Enter the name of the runner group
(Press Enter to use the default group):

# Enter the runner group name. Make sure it matches the group you created earlier.
2.  Enter the name of the runner
(Press Enter to accept the suggested default name, or type your own like staging-runner or prod-runner):

# Enter the runner name. For example
- Use "staging" for the staging server.
- Use "production" for the production server.
3. Enter additional labels (comma-separated)
(Optional — e.g. production,ubuntu or staging,ubuntu). These labels are what you’ll match later in your deploy.yml under runs-on.

# Enter the additional labels
- "self-hosted,staging" for the staging server.
- "self-hosted,production" for the production server.

# The labels must match the ones defined in your workflow file (we’ll explain this in a later step).

Install and start Runner

$ sudo ./svc.sh install
$ sudo ./svc.sh start

Check Runner status

$ sudo ./svc.sh status



Configure passwordless sudo for our user

Of course, every time we execute certain commands, we need to use sudo, which normally asks for a password. Commands like chown, chmod, chgrp, or restarting services all require sudo privileges, and by default, it always prompts.

That’s fine when we’re on the server ourselves, but in an automated deployment (like GitHub Actions with deploy.yml, we'll explain later), there’s no way to type a password. The job would just fail.

To fix this, we set up passwordless sudo for the commands we actually need. This way, the workflow can run them automatically without stopping for input.

Here are the steps:

# Open the sudoers file
$ sudo visudo

Add the following entry at the bottom of the file, then save it:

# The user can execute chown, chmod, chgrp, find, or restart service without password
# Replace myuser with your actual server username
myuser ALL=(ALL) NOPASSWD: /bin/chown, /bin/chmod, /bin/chgrp, /usr/bin/find, /usr/bin/systemctl restart php8.3-fpm



Create a workflow for each repository

Next, we need to create a workflow file inside .github/workflows/deploy.yml

Below is an example setup for both the API and Frontend, covering staging and production environments.

/api/.github/workflows/deploy.yml

name: Deploy API

on:
  push:
    branches: [ main, staging ]
  workflow_dispatch:
    inputs:
      target_env:
        description: "Target environment"
        type: choice
        required: true
        options: [ staging, production ]

concurrency:
  group: api-${{ github.ref_name }}
  cancel-in-progress: true

jobs:
  choose-target:
    runs-on: ubuntu-latest
    outputs:
      target: ${{ steps.pick.outputs.target }}
      refname: ${{ steps.pick.outputs.refname }}
    steps:
      - id: pick
        run: |
          if [ -n "${{ github.event.inputs.target_env }}" ]; then
            echo "target=${{ github.event.inputs.target_env }}" >> $GITHUB_OUTPUT
          else
            if [ "${{ github.ref_name }}" = "main" ]; then
              echo "target=production" >> $GITHUB_OUTPUT
            else
              echo "target=staging" >> $GITHUB_OUTPUT
            fi
          fi
          echo "refname=${{ github.ref_name }}" >> $GITHUB_OUTPUT

  deploy:
    name: Deploy API (${{ needs.choose-target.outputs.target }})
    runs-on: ${{ needs.choose-target.outputs.target == 'production'
      && fromJSON('["self-hosted","production"]')
      || fromJSON('["self-hosted","staging"]') }}
    needs: choose-target
    steps:
      - name: Set paths & SSH key per environment
        run: |
          if [ "${{ needs.choose-target.outputs.target }}" = "production" ]; then
            echo "APP_PATH=/srv/apps/<your-application>/api" >> $GITHUB_ENV
            echo "HOME=/home/myuser" >> $GITHUB_ENV
            echo "GIT_SSH_COMMAND=ssh -i /home/myuser/.ssh/id_api -o IdentitiesOnly=yes" >> $GITHUB_ENV
          else
            echo "APP_PATH=/home/myuser/<your-application>/api" >> $GITHUB_ENV
            echo "HOME=/home/myuser" >> $GITHUB_ENV
            echo "GIT_SSH_COMMAND=ssh -i /home/myuser/.ssh/id_api -o IdentitiesOnly=yes" >> $GITHUB_ENV
          fi

      - name: Pre-fix ownership (allow git pull)
        run: |
          cd "$APP_PATH"
          sudo chown -R myuser:myuser .

      - name: Update code
        run: |
          set -euo pipefail
          git config --global --add safe.directory "$APP_PATH"
          cd "$APP_PATH"

          git fetch --prune origin ${{ needs.choose-target.outputs.refname }}
          git reset --hard origin/${{ needs.choose-target.outputs.refname }}

      - name: Install dependencies & cache
        run: |
          cd "$APP_PATH"
          if [ -f composer.json ]; then
            php composer.phar install --no-dev --prefer-dist --no-interaction --optimize-autoloader || true
            php artisan config:cache || true
            php artisan route:cache || true
          fi

      - name: Fix permissions back to www-data
        run: |
          cd "$APP_PATH"
          sudo chown -R www-data:www-data .
          sudo find . -type f -exec chmod 644 {} \;
          sudo find . -type d -exec chmod 755 {} \;
          sudo chgrp -R www-data storage bootstrap/cache
          sudo chmod -R ug+rwx storage bootstrap/cache

      - name: Restart PHP-FPM
        run: sudo systemctl restart php8.3-fpm

/web/.github/workflows/deploy.yml


name: Deploy Web

on:
  push:
    branches: [ main, staging ]
  workflow_dispatch:
    inputs:
      target_env:
        description: "Target environment"
        type: choice
        required: true
        options: [ staging, production ]

concurrency:
  group: web-${{ github.ref_name }}
  cancel-in-progress: true

jobs:
  choose-target:
    runs-on: ubuntu-latest
    outputs:
      target: ${{ steps.pick.outputs.target }}
      refname: ${{ steps.pick.outputs.refname }}
    steps:
      - id: pick
        run: |
          if [ -n "${{ github.event.inputs.target_env }}" ]; then
            echo "target=${{ github.event.inputs.target_env }}" >> $GITHUB_OUTPUT
          else
            if [ "${{ github.ref_name }}" = "main" ]; then
              echo "target=production" >> $GITHUB_OUTPUT
            else
              echo "target=staging" >> $GITHUB_OUTPUT
            fi
          fi
          echo "refname=${{ github.ref_name }}" >> $GITHUB_OUTPUT

  deploy:
    name: Deploy Web (${{ needs.choose-target.outputs.target }})
    runs-on: ${{ needs.choose-target.outputs.target == 'production'
      && fromJSON('["self-hosted","production"]')
      || fromJSON('["self-hosted","staging"]') }}
    needs: choose-target
    steps:
      - name: Set paths, PM2 file & SSH key per environment
        run: |
          if [ "${{ needs.choose-target.outputs.target }}" = "production" ]; then
            echo "APP_PATH=/srv/apps/<your-application>/web" >> $GITHUB_ENV
            echo "PM2_FILE=/srv/apps/<your-application>/ecosystem.config.js" >> $GITHUB_ENV
            echo "HOME=/home/myuser" >> $GITHUB_ENV
            echo "GIT_SSH_COMMAND=ssh -i /home/myuser/.ssh/id_web -o IdentitiesOnly=yes" >> $GITHUB_ENV
          else
            echo "APP_PATH=/home/myuser/apps/web" >> $GITHUB_ENV
            echo "PM2_FILE=/home/myuser/apps/ecosystem.config.js" >> $GITHUB_ENV
            echo "HOME=/home/myuser" >> $GITHUB_ENV
            echo "GIT_SSH_COMMAND=ssh -i /home/myuser/.ssh/id_web -o IdentitiesOnly=yes" >> $GITHUB_ENV
          fi

      - name: Change ownership root project
        run: |
          cd "$APP_PATH"
          sudo chown -R myuser:myuser .

      - name: Update code and build in temp dir
        run: |
          set -euo pipefail
          git config --global --add safe.directory "$APP_PATH"
          cd "$APP_PATH"

          git fetch --prune origin ${{ needs.choose-target.outputs.refname }}
          git reset --hard origin/${{ needs.choose-target.outputs.refname }}

          if [ -f package.json ]; then
            if command -v yarn >/dev/null 2>&1; then
              yarn install --frozen-lockfile || yarn install

              TS=$(date +%Y%m%d%H%M%S)
              BUILD_DIR=".output-${TS}"

              # Build directly into .output, then rename
              npx nuxi build --dotenv .env.production
              mv .output "$BUILD_DIR"

              echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
            else
              echo "Yarn is not installed on this server"
              exit 1
            fi
          fi

      - name: Swap build atomically
        run: |
          cd "$APP_PATH"
          if [ -d ".output" ]; then
            mv .output ".output-old-$(date +%s)"
          fi
          mv "$BUILD_DIR" .output

      - name: Change ownership back to www-data
        run: |
          cd "$APP_PATH"
          sudo chown -R www-data:www-data .output

      - name: Reload PM2 processes
        run: |
          pm2 reload "$PM2_FILE"
          pm2 save

      - name: Cleanup old builds
        run: |
          cd "$APP_PATH"
          rm -rf .output-old-*

You can add each step in the workflow by declaring a name (to describe the step) and a run (to define the command to execute), assuming the command needs to be run on the server.

Note that the runs-on section must include self-hosted and match the labels you configured for your GitHub runner.

The cancel-in-progress option should be set to true if you want new jobs to replace any currently running job, ensuring the latest one runs immediately.

The use of sudo won’t prompt for a password here because we already configured passwordless sudo in Step 8.

Test run

Finally, to test the setup, push some code to the staging branch. GitHub Actions will automatically trigger the deployment workflow and update the server. For more details, we can open the Actions tab in the repository to view the workflow logs and track the status of each run.

© 2024 Roslan Saidi