Continuous Deployment with GitHub Actions

A guide of deploying Laravel application to multiple servers using GitHub Actions without any downtime. This approach also works for any application, like WordPress, Symfony, the technique will be the same.

I write this documentary as a brief for myself thanks to a posy by Philo Hermans. I will add references at the bottom. Please see the links for detailed instructions.

I assume that you already have a Laravel application on your GitHub repository.

• First we create our secrets in Github. In your repository, go to settings > secrets and variables > actions.

Here we will add ssh secrets to connect our server:

    • SSH_HOST_1
    • SSH_PATH_1
    • SSH_PORT_1
    • SSH_PRIVATE_KEY_1
    • SSH_USERNAME_1

Also we will add .env file content for production. I named it like this:

    • LARAVEL_ENV

• Next step is to create deployment-config.json file where we keep server data. With this configuration, we are able to add multiple servers.

[
    {
        "name": "1",
        "ip": "${SSH_HOST_1}",
        "username": "${SSH_USERNAME_1}",
        "key": "${SSH_PRIVATE_KEY_1}",
        "port": "${SSH_PORT_1}",
        "path": "${SSH_PATH_1}",
        "beforeHooks": "chmod -R 0777 ${RELEASE_PATH}/storage",
        "afterHooks": "php ${ACTIVE_RELEASE_PATH}/artisan config:clear, php ${ACTIVE_RELEASE_PATH}/artisan cache:clear, php ${ACTIVE_RELEASE_PATH}/artisan route:clear, php ${ACTIVE_RELEASE_PATH}/artisan view:clear"
    }
]

• Let’s work on our GitHub workflow now. 

In this workflow, we will create the following jobs:

  • Create GitHub Action build artifacts for deployment
    • Install NPM dependencies.
    • Compile CSS and Javascript assets.
    • Install Composer dependencies.
    • Archive our build and remove unnecessary data (e.g., node_modules).
    • Store our archive so we can deploy it to our servers.
  • Prepare release on all our servers
    • Ensure we have a directory that holds every release.
    • Ensure we have a storage directory that shares data between releases.
    • Ensure we have a current directory that links to the active release.
    • Extract our build files into our releases directory.
  • Run optional before hooks
    • Specific commands before the release is activated (e.g., chmod directories)
  • Activate the release
    • Activate our new release without any downtime.
  • Run optional after hooks
    • Specific commands after the release is activated (e.g., send a notification when the deployment completed)
  • Cleaning up
    • Delete old release artifacts living on every server

Here is the folder structure that we achieve in the end. Our app will be running under current folder. Actually current directory is not a real folder but a link to the latest release of the app.

Let’s create .github/workflows/main.yml on your local and add the workflow below in this file.

name: 🚀 Deployment

on:
  push:
    branches: [main]

jobs:
  create-deployment-artifacts:
    name: 🎉 Deployment Artifacts
    runs-on: ubuntu-latest
    outputs:
      DEPLOYMENT_MATRIX: ${{ steps.export-deployment-matrix.outputs.DEPLOYMENT_MATRIX }}
    steps:
      - uses: actions/checkout@v4

      - name: Compile Css and Js Assets
        run: |
          npm install
          npm run build

      - name: Setup PHP with PECL extension
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
          extensions: mbstring,PDO,grpc,tokenizer,xml,json,ctype,fileinfo,openssl,bcmath

      - name: Install Composer Dependencies
        run: |
          composer install -q --no-dev --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

      - name: Create deployment artifact
        env:
          GITHUB_SHA: ${{ github.sha }}
        run: tar -czf "${GITHUB_SHA}".tar.gz --exclude=*.git --exclude=node_modules --exclude=tests *

      - name: Store artifact for distribution
        uses: actions/upload-artifact@v4
        with:
          name: app-build
          path: ${{ github.sha }}.tar.gz

      - name: Export deployment matrix
        id: export-deployment-matrix
        run: |
          delimiter="$(openssl rand -hex 8)"
          SERVER_NAMES=$(jq -c '[.[].name]' deployment-config.json)
          echo "DEPLOYMENT_MATRIX<<${delimiter}" >> "${GITHUB_OUTPUT}"
          echo "$SERVER_NAMES" >> "${GITHUB_OUTPUT}"
          echo "${delimiter}" >> "${GITHUB_OUTPUT}"

  prepare-release-on-servers:
    name: "Prepare release on ${{ matrix.server }}"
    runs-on: ubuntu-latest
    needs: create-deployment-artifacts
    strategy:
      matrix:
        server: ${{ fromJson(needs.create-deployment-artifacts.outputs.DEPLOYMENT_MATRIX) }}
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: app-build

      - name: Ensure remote directory exists
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets[format('SSH_HOST_{0}', matrix.server)] }}
          username: ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}
          key: ${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}
          port: ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }}
          script: |
            mkdir -p ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/artifacts

      - name: Upload using sftp
        env:
          ARTIFACT_PATH: ${{ github.sha }}.tar.gz
          REMOTE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/artifacts/${{ github.sha }}.tar.gz
        run: |
          echo "${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}" > /tmp/ssh_key
          chmod 600 /tmp/ssh_key
          sftp -o StrictHostKeyChecking=no -i /tmp/ssh_key -P ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }} ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}@${{ secrets[format('SSH_HOST_{0}', matrix.server)] }} <<EOF
          put $ARTIFACT_PATH $REMOTE_PATH
          EOF
          rm /tmp/ssh_key

      - name: Extract archive and create directories
        uses: appleboy/ssh-action@master
        env:
          GITHUB_SHA: ${{ github.sha }}
        with:
          host: ${{ secrets[format('SSH_HOST_{0}', matrix.server)] }}
          username: ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}
          key: ${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}
          port: ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }}
          envs: GITHUB_SHA
          script: |
            mkdir -p "${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases/${GITHUB_SHA}"
            tar xzf ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/artifacts/${GITHUB_SHA}.tar.gz -C "${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases/${GITHUB_SHA}"
            rm -rf ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases/${GITHUB_SHA}/storage

            mkdir -p ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/storage/{app,public,framework,logs}
            mkdir -p ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/storage/framework/{cache,sessions,testing,views}
            chmod -R 0777 ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/storage

  run-before-hooks:
    name: "${{ matrix.server.name }}: Before hook"
    runs-on: ubuntu-latest
    needs: [create-deployment-artifacts, prepare-release-on-servers]
    strategy:
      matrix:
        server: ${{ fromJson(needs.create-deployment-artifacts.outputs.DEPLOYMENT_MATRIX) }}
    steps:
      - name: Run before hooks
        uses: appleboy/ssh-action@master
        env:
          GITHUB_SHA: ${{ github.sha }}
          RELEASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases/${{ github.sha }}
          ACTIVE_RELEASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/current
          STORAGE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/storage
          BASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}
        with:
          host: ${{ secrets[format('SSH_HOST_{0}', matrix.server)] }}
          username: ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}
          key: ${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}
          port: ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }}
          envs: GITHUB_SHA,RELEASE_PATH,ACTIVE_RELEASE_PATH,STORAGE_PATH,BASE_PATH
          script: |
            ${{ matrix.server.beforeHooks }}

  activate-release:
    name: "${{ matrix.server.name }}: Activate release"
    runs-on: ubuntu-latest
    needs:
      [
        create-deployment-artifacts,
        prepare-release-on-servers,
        run-before-hooks,
      ]
    strategy:
      matrix:
        server: ${{ fromJson(needs.create-deployment-artifacts.outputs.DEPLOYMENT_MATRIX) }}
    steps:
      - name: Activate release
        uses: appleboy/ssh-action@master
        env:
          TERM: xterm
          GITHUB_SHA: ${{ github.sha }}
          RELEASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases/${{ github.sha }}
          ACTIVE_RELEASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/current
          STORAGE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/storage
          BASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}
          LARAVEL_ENV: ${{ secrets.LARAVEL_ENV }}
        with:
          host: ${{ secrets[format('SSH_HOST_{0}', matrix.server)] }}
          username: ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}
          key: ${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}
          port: ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }}
          envs: TERM,GITHUB_SHA,RELEASE_PATH,ACTIVE_RELEASE_PATH,STORAGE_PATH,BASE_PATH,ENV_PATH,LARAVEL_ENV
          script: |
            printf "%s" "$LARAVEL_ENV" > "${BASE_PATH}/.env"
            ln -s -f ${BASE_PATH}/.env $RELEASE_PATH
            ln -s -f $STORAGE_PATH $RELEASE_PATH
            ln -s -n -f $RELEASE_PATH $ACTIVE_RELEASE_PATH

  run-after-hooks:
    name: "${{ matrix.server.name }}: After hook"
    runs-on: ubuntu-latest
    needs:
      [
        create-deployment-artifacts,
        prepare-release-on-servers,
        run-before-hooks,
        activate-release,
      ]
    strategy:
      matrix:
        server: ${{ fromJson(needs.create-deployment-artifacts.outputs.DEPLOYMENT_MATRIX) }}
    steps:
      - name: Run after hooks
        uses: appleboy/ssh-action@master
        env:
          GITHUB_SHA: ${{ github.sha }}
          RELEASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases/${{ github.sha }}
          ACTIVE_RELEASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/current
          STORAGE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/storage
          BASE_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}
        with:
          host: ${{ secrets[format('SSH_HOST_{0}', matrix.server)] }}
          username: ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}
          key: ${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}
          port: ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }}
          envs: GITHUB_SHA,RELEASE_PATH,ACTIVE_RELEASE_PATH,STORAGE_PATH,BASE_PATH
          script: |
            ${{ matrix.server.afterHooks }}

  clean-up:
    name: "${{ matrix.server.name }}: Clean up"
    runs-on: ubuntu-latest
    needs:
      [
        create-deployment-artifacts,
        prepare-release-on-servers,
        run-before-hooks,
        activate-release,
        run-after-hooks,
      ]
    strategy:
      matrix:
        server: ${{ fromJson(needs.create-deployment-artifacts.outputs.DEPLOYMENT_MATRIX) }}
    steps:
      - name: Run after hooks
        uses: appleboy/ssh-action@master
        env:
          RELEASES_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/releases
          ARTIFACTS_PATH: ${{ secrets[format('SSH_PATH_{0}', matrix.server)] }}/artifacts
        with:
          host: ${{ secrets[format('SSH_HOST_{0}', matrix.server)] }}
          username: ${{ secrets[format('SSH_USERNAME_{0}', matrix.server)] }}
          key: ${{ secrets[format('SSH_KEY_{0}', matrix.server)] }}
          port: ${{ secrets[format('SSH_PORT_{0}', matrix.server)] }}
          envs: RELEASES_PATH,ARTIFACTS_PATH
          script: |
            if [ -z "$RELEASES_PATH" ]; then
              echo "RELEASES_PATH is not set or is empty. Exiting."
              exit 1
            fi
            if [ -z "$ARTIFACTS_PATH" ]; then
              echo "ARTIFACTS_PATH is not set or is empty. Exiting."
              exit 1
            fi

            if [ ! -d "$RELEASES_PATH" ]; then
              echo "Directory $RELEASES_PATH does not exist. Exiting."
              exit 1
            fi
            if [ ! -d "$ARTIFACTS_PATH" ]; then
              echo "Directory $ARTIFACTS_PATH does not exist. Exiting."
              exit 1
            fi

            cd $RELEASES_PATH && ls -t -1 | tail -n +6 | xargs rm -rf
            cd $ARTIFACTS_PATH && ls -t -1 | tail -n +6 | xargs rm -rf

Ok we are ready. Now we commit the changes and push to our GitHub repository main branch. As workflow is running on push to main branch (defined at the top of the workflow file), it will star running automatically after each push to main branch..

We can see it on the GitHub repository, Actions tab.

References: