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.