- Published on
Managing Laravel Vapor for White-Label Projects
- Authors
- Name
- Dan Mason
- @danmasonmp
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:
id: 2
name: vapor-laravel-app
environments:
production:
# config here...
test:
# 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.
$ tree -a
├── ABC
│ ├── .env.production.encrypted
│ ├── .env.staging.encrypted
│ ├── .env.test.encrypted
│ ├── production.yml
│ ├── staging.yml
│ └── test.yml
├── DEF
│ ├── .env.production.encrypted
│ ├── .env.staging.encrypted
│ ├── .env.test.encrypted
│ ├── production.yml
│ ├── staging.yml
│ └── test.yml
├── production.Dockerfile
├── staging.Dockerfile
└── 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.
name: "Deploy to Cloud"
on:
release:
types: [published]
jobs:
deploy:
runs-on: ubuntu-latest
env:
TAG_NAME: ${{ github.event.inputs.tag }}
CLIENT_REF: ${{ github.event.inputs.client }}
BUILD_NUMBER: ${{ github.run_number }}
COMMIT_HASH: ${{ github.sha }}
name: ${{ github.event.release.tag_name }} (${{ github.run_number }})
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Upload to S3
uses: jakejarvis/s3-sync-action@master
with:
args: --follow-symlinks --delete --exclude '.git/*' --exclude '.github/*' --exclude '.gitignore' --exclude '*.sh' --exclude '*.md' --exclude '.editorconfig'
env:
AWS_S3_BUCKET: vapor-client-configuration
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
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:
$ php artisan vapor:list production
{"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:
$ php artisan vapor:deploy production ABC
****************************************
* Deploying to ABC production! *
****************************************
Downloading vapor yaml config file (/ABC/production.yml).
Saving vapor yaml config file to base path.
Downloading file (production.Dockerfile).
Saving file to base path.
Downloading file (/ABC/.env.production.encrypted).
Saving file to base path.
Downloading environment file.
/home/runner/work/white-label-product/vendor/bin/vapor env:pull production
==> Downloading Environment File...
Environment variables written to [/home/runner/work/white-label-product/.env.production].
Running vapor deploy command!
Building project...
...
Project 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
name: "Deploy Nonprod to Vapor"
on:
release:
types: [ published ]
defaults:
run:
working-directory: site
jobs:
test:
runs-on: ubuntu-latest
outputs:
clients: ${{ steps.clients.outputs.content }}
environment: ${{ steps.environment.outputs.content }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
TAG_NAME: ${{ github.event.release.tag_name }}
BUILD_NUMBER: ${{ github.run_number }}
COMMIT_HASH: ${{ github.sha }}
name: Test and get clients to deploy
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: tmc_test
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
- name: Install yarn
run: npm install -g yarn
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v3
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: dependencies-js-16.x-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
dependencies-js-16.x-yarn-
- name: Setup PHP 8.2
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
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
coverage: pcov
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: dependencies-php-8.2-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
dependencies-php-8.2-composer-
- name: Reset MySQL root user authentication method
run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports[3306] }} -uroot -e "alter user 'root'@'%' identified with mysql_native_password by ''"
- name: Prepare Laravel Application
run: cp .env.testing .env
- name: Install PHP dependencies (composer)
run: composer install --no-interaction
- name: Install JavaScript dependencies (yarn)
run: yarn --frozen-lockfile
- name: Lint and test frontend code
run: yarn test
- name: Build JavaScript assets
run: yarn production
- name: Execute PHP tests
run: composer test
env:
DB_HOST: 127.0.0.1
DB_PORT: ${{ job.services.mysql.ports[3306] }}
PHP_CS_FIXER_IGNORE_ENV: true
- name: Get environment
id: environment
run: |
if [[ ${{ contains(env.TAG_NAME, 'alpha') }} == true ]]; then
echo "content=test" >> $GITHUB_OUTPUT
fi
if [[ ${{ contains(env.TAG_NAME, 'alpha') }} == false ]]; then
echo "content=staging" >> $GITHUB_OUTPUT
fi
- name: Get clients to deploy
id: clients
run: |
content=`php artisan vapor:list ${{ steps.environment.outputs.content }}`
echo $content
echo "content=$content" >> $GITHUB_OUTPUT
deploy:
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.test.outputs.clients) }}
max-parallel: 10
name: ${{ matrix.client }} / ${{ matrix.environment }} / ${{ github.event.release.tag_name }} (${{ github.run_number }})
env:
TAG_NAME: ${{ github.event.release.tag_name }}
BUILD_NUMBER: ${{ github.run_number }}
COMMIT_HASH: ${{ github.sha }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: tmc_test
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
- name: Install yarn
run: npm install -g yarn
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v3
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: dependencies-js-16.x-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
dependencies-js-16.x-yarn-
- name: Setup PHP 8.2
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
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
coverage: pcov
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: dependencies-php-8.2-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
dependencies-php-8.2-composer-
- name: Reset MySQL root user authentication method
run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports[3306] }} -uroot -e "alter user 'root'@'%' identified with mysql_native_password by ''"
- name: Prepare Laravel Application
run: cp .env.testing .env
- name: Install PHP dependencies (composer)
run: composer install --no-interaction
- name: Install JavaScript dependencies (yarn)
run: yarn --frozen-lockfile
- name: Get last commit message
id: last-commit-message
run: echo "value=$(git log -1 --pretty=format:"%s")" >> $GITHUB_OUTPUT
- name: Set package version
run: yarn set-version "$TAG_NAME"
- name: Deploy to ${{ matrix.client }} ${{ matrix.environment }}
run: php artisan vapor:deploy ${{ matrix.environment }} ${{ matrix.client }} --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"
env:
DB_HOST: 127.0.0.1
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
name: "Manual Deploy to Vapor"
on:
workflow_dispatch:
inputs:
client:
description: "Client reference"
required: true
tag:
description: "Release tag"
required: true
jobs:
test:
runs-on: ubuntu-latest
env:
TAG_NAME: ${{ github.event.inputs.tag }}
CLIENT_REF: ${{ github.event.inputs.client }}
BUILD_NUMBER: ${{ github.run_number }}
COMMIT_HASH: ${{ github.sha }}
name: ${{ github.event.inputs.client }} / ${{ github.event.inputs.tag }} (${{ github.run_number }})
defaults:
run:
working-directory: site
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: tmc_test
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
ref: 'refs/tags/${{ env.TAG_NAME }}'
- name: Setup Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
- name: Install yarn
run: npm install -g yarn
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v3
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: dependencies-js-16.x-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
dependencies-js-16.x-yarn-
- name: Setup PHP 8.2
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
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
coverage: pcov
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: dependencies-php-8.2-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
dependencies-php-8.2-composer-
- name: Reset MySQL root user authentication method
run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports[3306] }} -uroot -e "alter user 'root'@'%' identified with mysql_native_password by ''"
- name: Prepare Laravel Application
run: cp .env.testing .env
- name: Install PHP dependencies (composer)
run: composer install --no-interaction
- name: Migrate database
run: php artisan migrate --force
env:
DB_HOST: 127.0.0.1
DB_PORT: ${{ job.services.mysql.ports[3306] }}
- name: Get last commit message
id: last-commit-message
run: echo "value=$(git log -1 --pretty=format:"%s")" >> $GITHUB_OUTPUT
- name: Install JavaScript dependencies (yarn)
run: yarn --frozen-lockfile
- name: Set package version
run: yarn set-version "$TAG_NAME"
- name: Deploy to Test
if: contains(env.TAG_NAME, 'alpha')
env:
DB_HOST: 127.0.0.1
DB_PORT: ${{ job.services.mysql.ports[3306] }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}
run: php artisan vapor:deploy test $CLIENT_REF --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"
- name: Deploy to Stage
if: contains(env.TAG_NAME, 'beta')
env:
DB_HOST: 127.0.0.1
DB_PORT: ${{ job.services.mysql.ports[3306] }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}
run: php artisan vapor:deploy staging $CLIENT_REF --tag="$TAG_NAME" --commit="$COMMIT_HASH" --message="${{ steps.last-commit-message.outputs.value }}"
- name: Deploy to Prod
if: contains(env.TAG_NAME, 'alpha') == false && contains(env.TAG_NAME, 'beta') == false
env:
DB_HOST: 127.0.0.1
DB_PORT: ${{ job.services.mysql.ports[3306] }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}
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.
Want to talk about this post? Discuss this on Twitter →