I've been utilizing Laravel Vapor since it was first released to deploy our white-label product to numerous AWS accounts and projects. However, Vapor has a one-to-one relationship between your code and a Vapor project, which led us to create workarounds to support our setup.
Assuming you have a working knowledge of Vapor and how to configure and deploy it, in this post, I'll discuss our experience with Vapor and provide solutions to overcome these challenges. If you want to learn more about Vapor, check out the following resources:
Defining a white-label product
A white-label product is an unbranded software (website/app) that can be sold under contract. At my current job, we offer a white-label product that is hosted via Vapor but configured to show the client's brand and style it to match their marketing websites.
From the beginning, we decided to isolate each client's data from one another, including separating the test/staging database from the production database. To achieve this, we created one nonprod AWS account for all clients for all test/staging environments, and one production AWS account for each client. However, this approach does not align with how the Vapor service expects you to structure your projects, which I'll discuss in the next section.
Structuring Teams and Projects in the Laravel Vapor dashboard
Since each client has two AWS accounts, we needed to determine how to structure this in Vapor. Fortunately, Vapor allows you to create unlimited teams, each with unlimited projects. Additionally, you can add multiple AWS accounts to a team, and when creating a project within that team, it asks you to select which AWS account it's for.
We have a NONPROD
team which contains a project for each client, and each of those clients
has a test and staging environment:
Then for production
we decided to create each client as a team, and each team would have a prod
project.
Then inside that project there is a production
environment that is deployed to the client's AWS account
with its private database, Redis cache, and NAT gateway.
Due to this setup, we now have different teams for each client, and our test/staging/prod environments have
different project IDs. This creates an issue when setting up the vapor.yml
, which I will address
in the next section.
The Vapor YAML File is restrictive
The vapor.yml
file used to configure environments in Vapor is designed to support only one project,
specified by the id
and name
fields:
1id: 22name: vapor-laravel-app3environments:4 production:5 # config here...6 test:7 # config here...
We realized that we would need multiple vapor.yml
files for each client and project.
When deploying a specific client, we would need to retrieve the appropriate vapor.yml for that client
and environment combination. Additionally, our solution would need to work well with our GitHub Actions
setup to automate deployments for all clients.
Step 1: Create a repository for our vapor configs, encrypted environment files and dockerfiles
While it was tempting to add a new folder to our white-label code with separate Vapor configurations, we didn't want to have to release that code every time we added a new client. Instead, we decided to create a separate repository in GitHub with a clear folder structure.
1$ tree -a 2├── ABC 3│ ├── .env.production.encrypted 4│ ├── .env.staging.encrypted 5│ ├── .env.test.encrypted 6│ ├── production.yml 7│ ├── staging.yml 8│ └── test.yml 9├── DEF10│ ├── .env.production.encrypted11│ ├── .env.staging.encrypted12│ ├── .env.test.encrypted13│ ├── production.yml14│ ├── staging.yml15│ └── test.yml16├── production.Dockerfile17├── staging.Dockerfile18└── test.Dockerfile
As you can see above we structured the repository in the same way as they are structured in Vapor. Identifying the correct configurations to create or update are easy to find with this structure and will also help when trying to identify which config to get when automating our deployment.
Step 2: Deploy versioned Vapor configs to S3
With the vapor repository created in step 1 we decided that we wanted to be able to version the configs and deploy them somewhere separate. This is to ensure that when GitHub Actions is running our automated deployment we are confident that it is going to be using the correct configs and not accidentally deploy a new client when they are not ready yet.
We have experience deploying to S3 using GitHub Actions as the frontend of our application is a React SPA and is deployed to S3
with cloudfront. So we created a new S3 bucket in our central AWS account and put a GitHub Actions deploy.yml
workflow file
in our vapor config repository.
1name: "Deploy to Cloud" 2 3on: 4 release: 5 types: [published] 6 7jobs: 8 deploy: 9 10 runs-on: ubuntu-latest11 12 env:13 TAG_NAME: ${{ github.event.inputs.tag }}14 CLIENT_REF: ${{ github.event.inputs.client }}15 BUILD_NUMBER: ${{ github.run_number }}16 COMMIT_HASH: ${{ github.sha }}17 18 name: ${{ github.event.release.tag_name }} (${{ github.run_number }})19 20 steps:21 - name: Checkout code22 uses: actions/checkout@v323 24 - name: Upload to S325 uses: jakejarvis/s3-sync-action@master26 with:27 args: --follow-symlinks --delete --exclude '.git/*' --exclude '.github/*' --exclude '.gitignore' --exclude '*.sh' --exclude '*.md' --exclude '.editorconfig'28 env:29 AWS_S3_BUCKET: vapor-client-configuration30 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}31 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}32 AWS_REGION: 'eu-west-2'
Now, whenever we tag a new release GitHub Actions will automatically upload that version of the configs to our newly created S3 bucket.
Step 3: Create a vapor:list
artisan command that lists all clients for a specific environment
All of our client configs that are now available on S3 are now our single source of truth for what clients we need to
deploy for a specific environment. We needed a command in our white-label repository that will output a json list for
GitHub Actions to use as a matrix
, this will make more sense later when we explain how we use GitHub Actions to deploy
to all of our clients.
Running this command would output:
1$ php artisan vapor:list production2{"include":["ABC","DEF"]}
Step 4: Create a vapor:deploy
artisan command for deploying clients
We also need a command that you can pass both a {client}
and{environment}
as arguments to download all of the required
configuration files and run the vendor/bin/vapor deploy {environment}
command.
Running this command would output:
1$ php artisan vapor:deploy production ABC 2**************************************** 3* Deploying to ABC production! * 4**************************************** 5 6Downloading vapor yaml config file (/ABC/production.yml). 7Saving vapor yaml config file to base path. 8Downloading file (production.Dockerfile). 9Saving file to base path.10Downloading file (/ABC/.env.production.encrypted).11Saving file to base path.12Downloading environment file.13/home/runner/work/white-label-product/vendor/bin/vapor env:pull production14==> Downloading Environment File...15Environment variables written to [/home/runner/work/white-label-product/.env.production].16Running vapor deploy command!17Building project...18...19Project deployed successfully. (4m20s)
Our GitHub Actions Deployment Workflow
Finally, we are ready to set up our GitHub Actions to run the commands we created in Step 3 and 4.
Test & Staging deployment
Most people deploy test from a main or default branch, conversely, we group our changes as a release and when
we tag a release for test we will number it like so 2.7.1-beta.4
. The final number is essentially our release
candidate version, and we increase that number as we fix any bugs reported by the testers.
For the test
environment we automatically deploy to all clients where the release tags contains alpha
,
for example, 4.2.0-alpha.1
. For the staging
environment we automatically deploy to all clients where the release tags does not contain alpha
,
for example, 4.2.0-beta.1
or 4.2.0
.
vapor-nonprod-deploy.yml
1name: "Deploy Nonprod to Vapor" 2 3on: 4 release: 5 types: [ published ] 6 7defaults: 8 run: 9 working-directory: site 10 11jobs: 12 test: 13 14 runs-on: ubuntu-latest 15 16 outputs: 17 clients: ${{ steps.clients.outputs.content }} 18 environment: ${{ steps.environment.outputs.content }} 19 20 env: 21 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 22 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 23 TAG_NAME: ${{ github.event.release.tag_name }} 24 BUILD_NUMBER: ${{ github.run_number }} 25 COMMIT_HASH: ${{ github.sha }} 26 27 name: Test and get clients to deploy 28 29 services: 30 mysql: 31 image: mysql:8.0 32 env: 33 MYSQL_ALLOW_EMPTY_PASSWORD: yes 34 MYSQL_DATABASE: tmc_test 35 ports: 36 - 3306 37 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 38 39 steps: 40 - name: Checkout code 41 uses: actions/checkout@v3 42 43 - name: Setup Node.js 16.x 44 uses: actions/setup-node@v3 45 with: 46 node-version: 16.x 47 48 - name: Install yarn 49 run: npm install -g yarn 50 51 - name: Get yarn cache directory path 52 id: yarn-cache-dir-path 53 run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 54 55 - name: Cache yarn dependencies 56 uses: actions/cache@v3 57 with: 58 path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 59 key: dependencies-js-16.x-yarn-${{ hashFiles('**/yarn.lock') }} 60 restore-keys: | 61 dependencies-js-16.x-yarn- 62 63 - name: Setup PHP 8.2 64 uses: shivammathur/setup-php@v2 65 with: 66 php-version: 8.2 67 extensions: ctype, curl, date, dom, fileinfo, filter, gd, hash, iconv, intl, json, libxml, mbstring, openssl, pcntl, pcre, pdo, pdo_sqlite, pdo_mysql, phar, posix, simplexml, spl, sqlite, tokenizer, tidy, xml, xmlreader, xmlwriter, zip, zlib 68 coverage: pcov 69 70 - name: Get composer cache directory 71 id: composer-cache 72 run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 73 74 - name: Cache composer dependencies 75 uses: actions/cache@v3 76 with: 77 path: ${{ steps.composer-cache.outputs.dir }} 78 key: dependencies-php-8.2-composer-${{ hashFiles('**/composer.lock') }} 79 restore-keys: | 80 dependencies-php-8.2-composer- 81 82 - name: Reset MySQL root user authentication method 83 run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports[3306] }} -uroot -e "alter user 'root'@'%' identified with mysql_native_password by ''" 84 85 - name: Prepare Laravel Application 86 run: cp .env.testing .env 87 88 - name: Install PHP dependencies (composer) 89 run: composer install --no-interaction 90 91 - name: Install JavaScript dependencies (yarn) 92 run: yarn --frozen-lockfile 93 94 - name: Lint and test frontend code 95 run: yarn test 96 97 - name: Build JavaScript assets 98 run: yarn production 99 100 - name: Execute PHP tests101 run: composer test102 env:103 DB_HOST: 127.0.0.1104 DB_PORT: ${{ job.services.mysql.ports[3306] }}105 PHP_CS_FIXER_IGNORE_ENV: true106 107 - name: Get environment108 id: environment109 run: |110 if [[ ${{ contains(env.TAG_NAME, 'alpha') }} == true ]]; then111 echo "content=test" >> $GITHUB_OUTPUT112 fi113 if [[ ${{ contains(env.TAG_NAME, 'alpha') }} == false ]]; then114 echo "content=staging" >> $GITHUB_OUTPUT115 fi116 117 - name: Get clients to deploy118 id: clients119 run: |120 content=`php artisan vapor:list ${{ steps.environment.outputs.content }}`121 echo $content122 echo "content=$content" >> $GITHUB_OUTPUT123 124 deploy:125 needs: test126 runs-on: ubuntu-latest127 strategy:128 fail-fast: false129 matrix: ${{ fromJson(needs.test.outputs.clients) }}130 max-parallel: 10131 132 name: ${{ matrix.client }} / ${{ matrix.environment }} / ${{ github.event.release.tag_name }} (${{ github.run_number }})133 134 env:135 TAG_NAME: ${{ github.event.release.tag_name }}136 BUILD_NUMBER: ${{ github.run_number }}137 COMMIT_HASH: ${{ github.sha }}138 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}139 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}140 VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}141 142 services:143 mysql:144 image: mysql:8.0145 env:146 MYSQL_ALLOW_EMPTY_PASSWORD: yes147 MYSQL_DATABASE: tmc_test148 ports:149 - 3306150 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3151 152 steps:153 - name: Checkout code154 uses: actions/checkout@v3155 156 - name: Setup Node.js 16.x157 uses: actions/setup-node@v3158 with:159 node-version: 16.x160 161 - name: Install yarn162 run: npm install -g yarn163 164 - name: Get yarn cache directory path165 id: yarn-cache-dir-path166 run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT167 168 - name: Cache yarn dependencies169 uses: actions/cache@v3170 with:171 path: ${{ steps.yarn-cache-dir-path.outputs.dir }}172 key: dependencies-js-16.x-yarn-${{ hashFiles('**/yarn.lock') }}173 restore-keys: |174 dependencies-js-16.x-yarn-175 176 - name: Setup PHP 8.2177 uses: shivammathur/setup-php@v2178 with:179 php-version: 8.2180 extensions: ctype, curl, date, dom, fileinfo, filter, gd, hash, iconv, intl, json, libxml, mbstring, openssl, pcntl, pcre, pdo, pdo_sqlite, pdo_mysql, phar, posix, simplexml, spl, sqlite, tokenizer, tidy, xml, xmlreader, xmlwriter, zip, zlib181 coverage: pcov182 183 - name: Get composer cache directory184 id: composer-cache185 run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT186 187 - name: Cache composer dependencies188 uses: actions/cache@v3189 with:190 path: ${{ steps.composer-cache.outputs.dir }}191 key: dependencies-php-8.2-composer-${{ hashFiles('**/composer.lock') }}192 restore-keys: |193 dependencies-php-8.2-composer-194 195 - name: Reset MySQL root user authentication method196 run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports[3306] }} -uroot -e "alter user 'root'@'%' identified with mysql_native_password by ''"197 198 - name: Prepare Laravel Application199 run: cp .env.testing .env200 201 - name: Install PHP dependencies (composer)202 run: composer install --no-interaction203 204 - name: Install JavaScript dependencies (yarn)205 run: yarn --frozen-lockfile206 207 - name: Get last commit message208 id: last-commit-message209 run: echo "value=$(git log -1 --pretty=format:"%s")" >> $GITHUB_OUTPUT210 211 - name: Set package version212 run: yarn set-version "$TAG_NAME"213 214 - name: Deploy to ${{ matrix.client }} ${{ matrix.environment }}215 run: php artisan vapor:deploy ${{ matrix.environment }} ${{ matrix.client }} --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"216 env:217 DB_HOST: 127.0.0.1218 DB_PORT: ${{ job.services.mysql.ports[3306] }}
You will notice that we use the output of the vapor:list
command as the matrix
of the deploy
step, this
will display like so in GitHub Actions and each client is deployed asynchronously:
Production deployment
We manually deploy to production using the GitHub Actions workflow_dispatch
hook. This allows us to define
fields for a form as shown below:
This is then passed to the GitHub Actions workflow as inputs and we pass the Client Reference
and the Release tag
to the vapor:deploy
command as {client}
and {environment}
arguments. The manual workflow also handles manually
deploying test
, staging
and production
releases.
vapor-manual-deploy.yml
1name: "Manual Deploy to Vapor" 2 3on: 4 workflow_dispatch: 5 inputs: 6 client: 7 description: "Client reference" 8 required: true 9 tag: 10 description: "Release tag" 11 required: true 12 13jobs: 14 test: 15 16 runs-on: ubuntu-latest 17 18 env: 19 TAG_NAME: ${{ github.event.inputs.tag }} 20 CLIENT_REF: ${{ github.event.inputs.client }} 21 BUILD_NUMBER: ${{ github.run_number }} 22 COMMIT_HASH: ${{ github.sha }} 23 24 name: ${{ github.event.inputs.client }} / ${{ github.event.inputs.tag }} (${{ github.run_number }}) 25 26 defaults: 27 run: 28 working-directory: site 29 30 services: 31 mysql: 32 image: mysql:8.0 33 env: 34 MYSQL_ALLOW_EMPTY_PASSWORD: yes 35 MYSQL_DATABASE: tmc_test 36 ports: 37 - 3306 38 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 39 40 steps: 41 - name: Checkout code 42 uses: actions/checkout@v3 43 with: 44 ref: 'refs/tags/${{ env.TAG_NAME }}' 45 46 - name: Setup Node.js 16.x 47 uses: actions/setup-node@v3 48 with: 49 node-version: 16.x 50 51 - name: Install yarn 52 run: npm install -g yarn 53 54 - name: Get yarn cache directory path 55 id: yarn-cache-dir-path 56 run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 57 58 - name: Cache yarn dependencies 59 uses: actions/cache@v3 60 with: 61 path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 62 key: dependencies-js-16.x-yarn-${{ hashFiles('**/yarn.lock') }} 63 restore-keys: | 64 dependencies-js-16.x-yarn- 65 66 - name: Setup PHP 8.2 67 uses: shivammathur/setup-php@v2 68 with: 69 php-version: 8.2 70 extensions: ctype, curl, date, dom, fileinfo, filter, gd, hash, iconv, intl, json, libxml, mbstring, openssl, pcntl, pcre, pdo, pdo_sqlite, pdo_mysql, phar, posix, simplexml, spl, sqlite, tokenizer, tidy, xml, xmlreader, xmlwriter, zip, zlib 71 coverage: pcov 72 73 - name: Get composer cache directory 74 id: composer-cache 75 run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 76 77 - name: Cache composer dependencies 78 uses: actions/cache@v3 79 with: 80 path: ${{ steps.composer-cache.outputs.dir }} 81 key: dependencies-php-8.2-composer-${{ hashFiles('**/composer.lock') }} 82 restore-keys: | 83 dependencies-php-8.2-composer- 84 85 - name: Reset MySQL root user authentication method 86 run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports[3306] }} -uroot -e "alter user 'root'@'%' identified with mysql_native_password by ''" 87 88 - name: Prepare Laravel Application 89 run: cp .env.testing .env 90 91 - name: Install PHP dependencies (composer) 92 run: composer install --no-interaction 93 94 - name: Migrate database 95 run: php artisan migrate --force 96 env: 97 DB_HOST: 127.0.0.1 98 DB_PORT: ${{ job.services.mysql.ports[3306] }} 99 100 - name: Get last commit message101 id: last-commit-message102 run: echo "value=$(git log -1 --pretty=format:"%s")" >> $GITHUB_OUTPUT103 104 - name: Install JavaScript dependencies (yarn)105 run: yarn --frozen-lockfile106 107 - name: Set package version108 run: yarn set-version "$TAG_NAME"109 110 - name: Deploy to Test111 if: contains(env.TAG_NAME, 'alpha')112 env:113 DB_HOST: 127.0.0.1114 DB_PORT: ${{ job.services.mysql.ports[3306] }}115 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}116 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}117 VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}118 run: php artisan vapor:deploy test $CLIENT_REF --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"119 120 - name: Deploy to Stage121 if: contains(env.TAG_NAME, 'beta')122 env:123 DB_HOST: 127.0.0.1124 DB_PORT: ${{ job.services.mysql.ports[3306] }}125 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}126 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}127 VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}128 run: php artisan vapor:deploy staging $CLIENT_REF --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"129 130 - name: Deploy to Prod131 if: contains(env.TAG_NAME, 'alpha') == false && contains(env.TAG_NAME, 'beta') == false132 env:133 DB_HOST: 127.0.0.1134 DB_PORT: ${{ job.services.mysql.ports[3306] }}135 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}136 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}137 VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}138 run: php artisan vapor:deploy production $CLIENT_REF --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"
Conclusion
Here's a summary of the article so far. We use Laravel Vapor to deploy our white-label product to multiple AWS accounts and projects. However, Vapor only supports a one-to-one relation between your code and a Vapor project. Our setup with multiple AWS accounts per client and separation of databases did not align with how Vapor expects you to structure your projects. So, we had to find a workaround.
We created separate repositories on GitHub with different Vapor configurations for each client. This solution required
a way to download the specific configuration for a client and environment. We solved this problem by creating a
vapor:list
Artisan command to list all clients for a specific environment.
Although this solution is not perfect, it was a fun problem to solve. If you have a better solution, feel free to reach out to me on Twitter.
Syntax highlighting by Torchlight.dev