innocenzi / deployer-recipe-forge
Seamless zero-downtime deployment on Forge with Deployer
Requires
- php: ^8.2|^8.3
- deployer/deployer: ^7.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.59.3
README
Seamless zero-downtime deployment on Forge with Deployer
composer require --dev innocenzi/deployer-recipe-forge
About
deployer-recipe-forge
is a recipe for Deployer that helps implementing zero-downtime on Laravel Forge.
It uses Forge's API to fetch a site's credentials, such as its IP address and the remote username, so you don't have to hardcode them in your deploy.php
. It automatically finds the right Forge site using your repository's name, so the only configuration to do happens on Forge.
Additionally, it's able to send notifications on Slack with either Deployer's integration, which requires that you setup a webhook, or by pinging Forge's deployment URL.
Installation
Install the package as a development dependency:
composer require --dev innocenzi/deployer-recipe-forge
Then, create a deploy.php
file at the root of your project. Import the autoloader, and call \Deployer\Forge::make()->configure();
. You may then customize your deployment script as usual with Deployer.
namespace Deployer; // This is required require __DIR__ . '/vendor/autoload.php'; \Deployer\Forge::make()->configure(); // This is your custom deployment script task('deploy:build', function () { // build assets here runLocally('bun i'); runLocally('bun run build'); });
Usage
Setting up the repository
This recipe is designed to be used within a GitHub workflow, using the action provided by Deployer.
Setting it up requires at least two repository secrets:
- Your server's private SSH key, which will be used by
deployphp/action
to SSH - Your Forge API token, which will be used by the recipe to fetch your site and server's credentials
These repository secrets must then be forwarded to the action as environment variables, or, for the SSH key, as the private-key
argument:
- uses: deployphp/action@v1 with: private-key: ${{ secrets.PRIVATE_SSH_KEY }} dep: deploy env: FORGE_API_KEY: ${{ secrets.FORGE_API_KEY }} REPOSITORY_BRANCH: ${{ github.ref_name }} REPOSITORY_NAME: ${{ github.repository }}
Warning
Note that the REPOSITORY_BRANCH
and REPOSITORY_NAME
variables are required for the recipe to work, since they are used to match your site and branch to your site Forge.
Note
For convenience, if you are using a paid GitHub plan, you may setup your Forge API token and Slack webhook as organization-wide secrets.
Setting up Forge
Creating a new site
The following steps explain how to setup a new site on Forge to work with this recipe on GitHub Actions.
-
Create an isolated site.
Currently, this recipe only works with isolated Forge sites. You must then create an isolated site.
-
Install the repository using the GitHub integration.
You may uncheck "install composer dependencies" to make the installation faster, as the site directory will be erased anyway.
If you haven't associated the server's deploy key with your GitHub repository, make sure that you do or that you create a deploy key for this site. -
If necessary, add the server's public key (
~/.ssh/id_rsa.pub
) to the repository's deploy keys.This will allow Deployer to clone the repository from its SSH session on the server.
-
SSH into the server to copy its public key (
~/.ssh/id_rsa.pub
) and add it to the server's authorized keys on Forge.This is the first step to allow the Deployer action to SSH into the server.
The key should be associated to the correct isolated site username. -
Finally, copy the server's private key (
~/.ssh/id_rsa
) and add it to the repository's secrets.This is the last step to allow the Deployer action to SSH into the server.
We suggest naming the secret after the target environment. For instance, if you are creating a staging site, it may be namedSTAGING_SSH_KEY
. -
You may then trigger the deployment workflow.
Adding to an existing site
First, make sure the website is isolated. It must be located at /home/<isolated-username>/<site-name>
on the server. Otherwise, you must create a new isolated site.
You must first backup your .env
file and storage
directory, since Deployer will delete the site's directory to replace it with a symlink.
After backing them up, you may follow steps 3 to 6. Once the deployment is complete, you may restore your .env
and storage
in /home/<isolated-username>/deployer/<site-name>/shared
.
Warning
Proceed with caution. Make sure to put the site on maintenance and think of the potential side-effects that may happen during this migration.
Slack notifications
Using Deployer
This recipe supports sending Slack notifications using a webhook. To create it, you may follow Slack's documentation on the topic. You must then add the webhook as a repository or organization secret, and forward it to the action as an environment variable.
- uses: deployphp/action@v1 with: private-key: ${{ secrets.PRIVATE_SSH_KEY }} dep: deploy env: # ... # Add these two SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} RUNNER_ID: ${{ github.run_id }}
Note
Note that RUNNER_ID
is required, because the Slack notifications link to the associated GitHub workflow.
Using Forge
Alternatively, you may set up Slack notifications on Forge, and trigger a deployment on Forge. This way, you also get to use Forge's deployment history.
To trigger the deployment on Forge, simply call triggerDeploymentsOnForge
:
\Deployer\Forge::make() ->triggerDeploymentsOnForge() ->configure();
Warning
When using this strategy, make sure to empty the default deployment script configured by Forge.
Multiple environments
This recipe supports deploying to multiple environments, such as staging or production, without any specific configuration, other than adding the different private SSH keys to your repository secrets.
It works by associating the repository branch to the one defined on the Forge site. For instance, you may have a main
branch associated to example.com
, and a develop
branch associated to staging.example.com
.
For two environments
The private SSH key must be provided to the private-key
argument, for instance using a conditional, as follows:
- uses: deployphp/action@v1 with: private-key: ${{ github.ref_name == 'develop' && secrets.STAGING_SSH_KEY || secrets.PRODUCTION_SSH_KEY }} dep: deploy env: # ...
For more environments
If you have more than two environments, you may simply duplicate the actions and use an if
statement to ensure they run for the correct branches:
steps: - name: Deploy (staging) if: github.ref_name == 'develop' uses: deployphp/action@v1 with: private-key: ${{ secrets.STAGING_SSH_KEY }} dep: deploy # ... - name: Deploy (production) if: github.ref_name == 'main' uses: deployphp/action@v1 with: private-key: ${{ secrets.PRODUCTION_SSH_KEY }} dep: deploy # ...
Building on CI or on server
By default, the recipe will build assets on CI, and upload them on the server after. To build on the server, call buildOnServer
:
\Deployer\Forge::make() ->buildOnServer() ->configure();
Note that, by default, no deploy:build
task is provided, and you should write your own. For instance, you may build assets using Bun:
\Deployer\Forge::make()->configure(); task('deploy:build', function () { runLocally('bun i'); runLocally('bun run build'); });
Obviously, make sure Bun is installed on CI before using that example.
Example workflow
This workflow deploys on production or staging, depending on the branch that pushed, after running the test.yml
and style.yml
workflows. It will skip deployments if the commit body contains [skip deploy]
, and will notify about deployments on Slack.
Additionally, you may dispatch the workflow manually.
name: Deploy on: workflow_dispatch: push: branches: [main, develop] concurrency: group: ${{ github.ref }} jobs: test: uses: ./.github/workflows/test.yml style: uses: ./.github/workflows/style.yml deploy: needs: [test, style] if: ${{ !contains(github.event.head_commit.message, '[skip deploy]') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: shivammathur/setup-php@v2 with: php-version: 8.2 - uses: oven-sh/setup-bun@v1 with: bun-version: latest - name: Install dependencies run: composer install --no-interaction --prefer-dist - uses: deployphp/action@v1 with: private-key: ${{ github.ref_name == 'develop' && secrets.STAGING_SSH_KEY || secrets.PRODUCTION_SSH_KEY }} dep: deploy env: FORGE_API_KEY: ${{ secrets.FORGE_API_KEY }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} REPOSITORY_BRANCH: ${{ github.ref_name }} REPOSITORY_NAME: ${{ github.repository }} RUNNER_ID: ${{ github.run_id }}
Using dep
locally
Since the recipe depends on multiple environment variables, you cannot easily call vendor/bin/dep
locally.
You may work around that limitation by adding FORGE_API_KEY
to your .env
, as well as REPOSITORY_NAME
and REPOSITORY_BRANCH
:
FORGE_API_KEY="abc...123" REPOSITORY_NAME="yourorg/repo" REPOSITORY_BRANCH="develop"
Then, you may add the following script to composer.json
, and run composer dep
instead of vendor/bin/dep
:
"dep": "set -o allexport && source ./.env && set +o allexport && vendor/bin/dep"
Warning
Using dep
locally will create a .deployer_cache
file, which you should add to .gitignore
.
Note
If you get denied SSH access, make sure to add your public key on your server and associate it to your site's user.
Q&A
Why not Envoyer?
Envoyer's integration with Forge removes the convenience of being able to manage everything within Forge directly, instead forcing us to juggle between two different user interfaces. Our developer experience is better with Deployer and this recipe.
Why are issues closed?
This recipe is mostly used internally at Jetfly, and we are not willing to do support for it nor add many features. Pull requests for fixes and small additions may be welcome, though.