Skip to content

Managing Laravel Vapor for White-Label Projects

Posted by author

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: Vapor dashboard showing our nonprod projects setup

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. Vapor dashboard showing our production team and project setup

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: 2
2name: vapor-laravel-app
3environments:
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├── DEF
10   ├── .env.production.encrypted
11   ├── .env.staging.encrypted
12   ├── .env.test.encrypted
13   ├── production.yml
14   ├── staging.yml
15   └── test.yml
16├── production.Dockerfile
17├── staging.Dockerfile
18└── 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-latest
11 
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 code
22 uses: actions/checkout@v3
23 
24 - name: Upload to S3
25 uses: jakejarvis/s3-sync-action@master
26 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-configuration
30 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 production
2{"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 production
14==> 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 tests
101 run: composer test
102 env:
103 DB_HOST: 127.0.0.1
104 DB_PORT: ${{ job.services.mysql.ports[3306] }}
105 PHP_CS_FIXER_IGNORE_ENV: true
106 
107 - name: Get environment
108 id: environment
109 run: |
110 if [[ ${{ contains(env.TAG_NAME, 'alpha') }} == true ]]; then
111 echo "content=test" >> $GITHUB_OUTPUT
112 fi
113 if [[ ${{ contains(env.TAG_NAME, 'alpha') }} == false ]]; then
114 echo "content=staging" >> $GITHUB_OUTPUT
115 fi
116 
117 - name: Get clients to deploy
118 id: clients
119 run: |
120 content=`php artisan vapor:list ${{ steps.environment.outputs.content }}`
121 echo $content
122 echo "content=$content" >> $GITHUB_OUTPUT
123 
124 deploy:
125 needs: test
126 runs-on: ubuntu-latest
127 strategy:
128 fail-fast: false
129 matrix: ${{ fromJson(needs.test.outputs.clients) }}
130 max-parallel: 10
131 
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.0
145 env:
146 MYSQL_ALLOW_EMPTY_PASSWORD: yes
147 MYSQL_DATABASE: tmc_test
148 ports:
149 - 3306
150 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
151 
152 steps:
153 - name: Checkout code
154 uses: actions/checkout@v3
155 
156 - name: Setup Node.js 16.x
157 uses: actions/setup-node@v3
158 with:
159 node-version: 16.x
160 
161 - name: Install yarn
162 run: npm install -g yarn
163 
164 - name: Get yarn cache directory path
165 id: yarn-cache-dir-path
166 run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
167 
168 - name: Cache yarn dependencies
169 uses: actions/cache@v3
170 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.2
177 uses: shivammathur/setup-php@v2
178 with:
179 php-version: 8.2
180 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
181 coverage: pcov
182 
183 - name: Get composer cache directory
184 id: composer-cache
185 run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
186 
187 - name: Cache composer dependencies
188 uses: actions/cache@v3
189 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 method
196 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 Application
199 run: cp .env.testing .env
200 
201 - name: Install PHP dependencies (composer)
202 run: composer install --no-interaction
203 
204 - name: Install JavaScript dependencies (yarn)
205 run: yarn --frozen-lockfile
206 
207 - name: Get last commit message
208 id: last-commit-message
209 run: echo "value=$(git log -1 --pretty=format:"%s")" >> $GITHUB_OUTPUT
210 
211 - name: Set package version
212 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.1
218 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: GitHub Actions deploying a test environment

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: Manually deploying a production environment in a GitHub Action

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 message
101 id: last-commit-message
102 run: echo "value=$(git log -1 --pretty=format:"%s")" >> $GITHUB_OUTPUT
103 
104 - name: Install JavaScript dependencies (yarn)
105 run: yarn --frozen-lockfile
106 
107 - name: Set package version
108 run: yarn set-version "$TAG_NAME"
109 
110 - name: Deploy to Test
111 if: contains(env.TAG_NAME, 'alpha')
112 env:
113 DB_HOST: 127.0.0.1
114 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 Stage
121 if: contains(env.TAG_NAME, 'beta')
122 env:
123 DB_HOST: 127.0.0.1
124 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 Prod
131 if: contains(env.TAG_NAME, 'alpha') == false && contains(env.TAG_NAME, 'beta') == false
132 env:
133 DB_HOST: 127.0.0.1
134 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

End of article