Deployer: Build and Cache Frontend Assets once using GitHub Actions

• 5 min read

When I joined Trenda earlier this year, one of the first tasks given to me was adding support for zero downtime deployments.

I've written before how I use Deployer and how I trigger deployments using GitHub Actions.

In contrast to other projects, at Trenda, we don't commit the compiled CSS and JavaScript files to the repository. The files need to be compiled on each developers machine and during deployment.

Originally we ran npm ci; npm run build; on each deployment on each server. This works, but it makes for slow deployments.

One thing we noticed is that the compiled assets don't need to change on each deployment. Not every release changes the design of the app or adds new JavaScript functionality.

Sandro suggested that we somehow could use the "actions/cache"-Action here.

At the same time we were discussing the way forward, the Flare team shared "Rethinking deploys at Flare", in which they mention that they use a package called Airdrop to cache build assets.

A quick test showed that this would work for us as well, but Sandro's approach with using actions/cache would not require another composer dependency, and we would remain in "GitHub Actions land".

GitHub Actions Workflow for Deployer #

We took the workflow I've shared in 2021 to trigger deploys manually and added steps to build and cache the frontend assets. The steps look like this:

# Workflow triggers and more

- name: Install and cache composer dependencies
  uses: ramsey/composer-install@v3

- name: Frontend Assets Cache
  uses: actions/cache@v4
  id: frontend-assets-cache
  with:
    key: frontend-assets-${{ hashFiles('**/vite.config.js', '**/package-lock.json', '**/tailwind.config.js', '**/resources/**', '**/vendor/filament/**/*.blade.php') }}
    path: public/build/*

- uses: actions/setup-node@v4
  if: steps.frontend-assets-cache.outputs.cache-hit != 'true'
  with:
    node-version: 22
    cache: 'npm'

- name: Install Dependencies
  if: steps.frontend-assets-cache.outputs.cache-hit != 'true'
  run: npm ci

- name: Production Assets Build
  if: steps.frontend-assets-cache.outputs.cache-hit != 'true'
  run: npm run build

# More steps to deploy the app

The important part here is the key we generate in the "Frontend Assets Cache" step. The hashFiles-function generates a unique hash based on the files that match the patterns passed to the method. In this case, the hash is regenerated when one of the following files changes:

  • vite.config.js
  • package-lock.json
  • tailwind.config.js
  • resources/** (any CSS, JavaScript or Blade template file)
  • vendor/filament/**/*.blade.php (any Blade templates provided by Filament. We use a custom theme)

The generated cache is stored for 90 days on GitHub servers. If we would start adding Tailwind CSS classes in more places that are not covered in this list, we can just extend the list of files passed to hashFiles(). Easy-peasy.

Note that we cache the content of public/build/*. If your app would rely on code inside node_modules/ you might have to still run npm ci on your server or find a different solution.

The entire GitHub Actions workflow now looks like this.

name: Deploy

on:
  repository_dispatch:
    types: ['deploy']
  workflow_dispatch:
    inputs:
      deploy_env:
        description: 'Environment'
        required: true
        default: 'stag'
        type: choice
        options:
        - stag
        - prod

  deploy:
    name: Deploy
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - uses: ramsey/composer-install@v3

      - name: Frontend Assets Cache
        uses: actions/cache@v4
        id: frontend-assets-cache
        with:
          key: frontend-assets-${{ hashFiles('**/vite.config.js', '**/package-lock.json', '**/tailwind.config.js', '**/resources/**', '**/vendor/filament/**/*.blade.php') }}
          path: public/build/*

      - uses: actions/setup-node@v4
        if: steps.frontend-assets-cache.outputs.cache-hit != 'true'
        with:
          node-version: 22
          cache: 'npm'

      - name: Install Dependencies
        if: steps.frontend-assets-cache.outputs.cache-hit != 'true'
        run: npm ci

      - name: Production Assets Build
        if: steps.frontend-assets-cache.outputs.cache-hit != 'true'
        run: npm run build

      - name: Deploy
        uses: deployphp/action@master
        with:
          private-key: ${{ secrets.DEPLOY_KEY }}
          known-hosts: ${{ secrets.KNOWN_HOSTS }}
          dep: deploy ${{ github.event.inputs.deploy_env }} -v

Updating Deployer to upload the Assets #

The last remaining step is to tell deployer to upload the cached assets to our server. For this I've added a upload:vite-assets task and referenced it in our deploy-task.

<?php

task('upload:vite-assets', function () {
    // Check if the public/build directory exists
    runLocally('test -d public/build/');

    // Upload the assets if they exist.
    upload('public/build/', '{{release_or_current_path}}/public/build/');
});

desc('Deploy application');
task('deploy', [
    'deploy:prepare',
    'deploy:vendors',
    'upload:vite-assets', // ← our new task
    'artisan:storage:link',
    'artisan:view:cache',
    'artisan:config:cache',
    'artisan:route:cache',
    'artisan:view:cache',
    'artisan:migrate',
    'artisan:horizon:terminate',
    'deploy:publish',
    'php-fpm:reload',
    'artisan:cache:clear',
]);

When we now deploy our app using GitHub Actions, Actions will restore our public/build/ directory if a cache exists, or it will install our NPM dependencies and create a new build and cache that build.

Deployer will then double-check if the public/build/ directory exists and will upload the files to our servers.

Wrapping Up #

We're really happy with this solution. It allows us to keep everything deployment related inside the GitHub Actions universe.

In addition, it allows us to easily upgrade the used Node version by changing node-version in the workflow. We now don't need to install Node on the server the app is being deployed to and don't have to care about upgrades.

And the biggest benefit is faster deployments. This change shaved off 1 to 2 minutes of each of our deployments. Other CI workflows don't rely on the frontend assets, but if they would, I would add the same steps to those workflows as well.