Skip to content

Deployment

The starter does not prescribe a single deployment platform. Northwestern teams use a mix of AWS-native, container-based, and serverless architectures depending on application needs. The most common and fastest way to deploy a Laravel application internally is Laravel Vapor, Laravel’s serverless orchestration platform for AWS.

This guide provides:

  • A high-level overview of deployment considerations
  • A complete Vapor deployment walkthrough
  • Certificate + DNS workflow
  • Infrastructure-as-Code (IaC) integration (e.g., Terraform / OpenTofu)
  • GitHub Actions deployment pipeline examples
  • A reference vapor.yml
  • Known limitations and best practices

Your options typically include:

  • Laravel Vapor (recommended for new projects)
  • ECS / EC2 container-based deployments
  • Traditional EC2 hosts

Most Northwestern teams choose Vapor, so the remainder of this documentation focuses on building, provisioning, and deploying your application using Vapor.

Laravel Vapor is a serverless deployment platform built specifically for Laravel applications. Vapor:

  • Builds and deploys your application as AWS Lambda functions
  • Creates API Gateway endpoints, CloudFront distributions, and supporting infrastructure
  • Manages environment variables, assets, S3 file uploads, and build artifacts
  • Works seamlessly with queues, scheduled tasks, and CLI operations

Vapor significantly reduces maintenance overhead for many small and medium-scale applications.

Northwestern mirrors the structure of AWS accounts:

  • A nonprod Vapor team → nonprod AWS account
  • A prod Vapor team → prod AWS account

Because Vapor associates one project with one team, every application ends up with two Vapor projects, each with their own project ID.

  1. Create the Vapor project

    Create one project inside the nonprod Vapor team and one inside the prod Vapor team.

  2. Request certificates

    Create ACM certificates for each environment hostname:

    Terminal window
    vendor/bin/vapor certificate dev-your-app.example.edu
    vendor/bin/vapor certificate qa-your-app.example.edu
    vendor/bin/vapor certificate your-app.example.edu
  3. Validate certificates via DNS

    Use your organization’s DNS request process to add the CNAME records Vapor provides.

  4. Request VPC subnet allocations

    Obtain subnet IDs from your cloud/networking team.

  5. Configure vapor.yml

    Add memory values, timeouts, subnets, security groups, queue names, and build/deploy steps.

  6. Run your first deploy

    Terminal window
    vendor/bin/vapor deploy develop

Serverless constraints that may impact your application:

  • Lambda max runtime: 15 minutes
  • API Gateway timeout: 29 seconds
  • File uploads must use S3 (direct-to-S3 strategy)
  • Long-running queue jobs must be chunked
  • Persistent disk requires optional EFS integration
  • Limited customization of PHP extensions and binaries

Rather than scattering secrets across GitHub Secrets and .env.* files, store nearly all variables in:

Terminal window
.env.production.encrypted
.env.develop.encrypted

Commit these files to the repo and have Vapor decrypt them on deploy.

Example update flow:

Terminal window
php artisan env:decrypt --env=production
php artisan env:encrypt --env=production
git add .
git commit -m "Update production env"

Vapor reads these encrypted files at deploy time, so you can treat them as the single source of truth for environment configuration.

Although Vapor can provision RDS, S3, and ElastiCache, teams often prefer OpenTofu for:

  • Engine version management
  • Bucket policy control
  • VPC placement
  • Parameter Store integration

Use OpenTofu/Terraform to provision long-lived infrastructure, and use Vapor primarily for application deployment and environment configuration.

Below is a simplified GitHub Actions template that can be adapted for your application.

name: Deploy
on:
push:
branches: [ develop, qa, production ]
workflow_dispatch:
concurrency:
group: env-${{ github.ref }}
env:
BRANCH_NAME: ${{ github.ref_name }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 📂 Checkout Repository
uses: actions/checkout@v5
with:
ref: ${{ github.head_ref }}
- name: 🔑 Set Secrets Based on Branch
run: |
if [ "$BRANCH_NAME" = "production" ]; then
echo "TERRAFORM_KEY_VAR_NAME=TF_KEY_PROD" >> $GITHUB_ENV
echo "TERRAFORM_SECRET_VAR_NAME=TF_SECRET_PROD" >> $GITHUB_ENV
echo "VAPOR_API_VAR_NAME=PROD_VAPOR_API_TOKEN" >> $GITHUB_ENV
sed -i 's/id: 123/id: 456/' vapor.yml # Example Vapor project ID swap
else
echo "TERRAFORM_KEY_VAR_NAME=TF_KEY_NONPROD" >> $GITHUB_ENV
echo "TERRAFORM_SECRET_VAR_NAME=TF_SECRET_NONPROD" >> $GITHUB_ENV
echo "VAPOR_API_VAR_NAME=NONPROD_VAPOR_API_TOKEN" >> $GITHUB_ENV
fi
- name: 🌐 Set Environment URL for Deployment
id: environment-url
run: |
if [ "$BRANCH_NAME" = "production" ]; then
echo "URL=https://your-app.northwestern.edu/" >> $GITHUB_OUTPUT
elif [ "$BRANCH_NAME" = "qa" ]; then
echo "URL=https://qa-your-app.northwestern.edu/" >> $GITHUB_OUTPUT
else
echo "URL=https://dev-your-app.northwestern.edu/" >> $GITHUB_OUTPUT
fi
- name: 🐘 Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
extensions: ctype, curl, dom, fileinfo, filter, hash, iconv, intl, json, libxml, mbstring, openssl, pcre, pdo, pdo_pgsql, phar, reflection, session, simplexml, sodium, tokenizer, xml, xmlreader, xmlwriter, zip
- name: 📦 Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.x.x
- name: 🟢 Setup NodeJS
uses: actions/setup-node@v6
with:
node-version: 25
cache: pnpm
- name: 🌱 Setup OpenTofu
uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.10.2
- name: 🔧 Install dependencies
run: |
composer install --no-interaction
cp .env.ci .env
php artisan key:generate
pnpm install
- name: 🧱 OpenTofu Init
env:
AWS_ACCESS_KEY_ID: ${{ secrets[env.TERRAFORM_KEY_VAR_NAME] }}
AWS_SECRET_ACCESS_KEY: ${{ secrets[env.TERRAFORM_SECRET_VAR_NAME] }}
TF_VAR_local_state_passphrase: ${{ secrets.TF_LOCAL_STATE_ENCRYPTION_KEY_2025_07 }}
TF_VAR_shared_state_passphrase: ${{ secrets.TF_SHARED_RESOURCES_STATE_ENCRYPTION_KEY_2025_07 }}
run: |
cd iac/${BRANCH_NAME}
tofu init
- name: 🧱 OpenTofu Apply and Output
env:
AWS_ACCESS_KEY_ID: ${{ secrets[env.TERRAFORM_KEY_VAR_NAME] }}
AWS_SECRET_ACCESS_KEY: ${{ secrets[env.TERRAFORM_SECRET_VAR_NAME] }}
TF_VAR_local_state_passphrase: ${{ secrets.TF_LOCAL_STATE_ENCRYPTION_KEY_2025_07 }}
TF_VAR_shared_state_passphrase: ${{ secrets.TF_SHARED_RESOURCES_STATE_ENCRYPTION_KEY_2025_07 }}
run: |
cd iac/${BRANCH_NAME}
tofu apply -auto-approve -no-color -var="master_password=${{ secrets.DB_PASSWORD }}"
db_name=$(tofu output -no-color -raw db_name)
db_endpoint=$(tofu output -no-color -raw db_endpoint)
db_username=$(tofu output -no-color -raw master_username)
bucket_name=$(tofu output -no-color -raw file_uploads_bucket_name)
echo "DB_HOST=$db_endpoint" >> $GITHUB_ENV
echo "DB_USERNAME=$db_username" >> $GITHUB_ENV
echo "DB_DATABASE=$db_name" >> $GITHUB_ENV
echo "BUCKET_NAME=$bucket_name" >> $GITHUB_ENV
- name: 🚀 Deploy App
env:
VAPOR_API_TOKEN: ${{ secrets[env.VAPOR_API_VAR_NAME] }}
SHA: ${{ github.sha }}
run: |
echo "${{ secrets.LARAVEL_ENV_ENCRYPTION_KEY }}" | vendor/bin/vapor secret --name "LARAVEL_ENV_ENCRYPTION_KEY" ${BRANCH_NAME}
echo "${DB_HOST}" | vendor/bin/vapor secret --name "DB_HOST" ${BRANCH_NAME}
echo "${DB_USERNAME}" | vendor/bin/vapor secret --name "DB_USERNAME" ${BRANCH_NAME}
echo "${DB_DATABASE}" | vendor/bin/vapor secret --name "DB_DATABASE" ${BRANCH_NAME}
echo "${{ secrets.DB_PASSWORD }}" | vendor/bin/vapor secret --name "DB_PASSWORD" ${BRANCH_NAME}
sleep 5;
echo "${BUCKET_NAME}" | vendor/bin/vapor secret --name "AWS_BUCKET" ${BRANCH_NAME}
vendor/bin/vapor deploy --no-ansi ${BRANCH_NAME} --commit=${SHA}
- name: 🔗 Attach Scratch EFS
env:
AWS_ACCESS_KEY_ID: ${{ secrets[env.TERRAFORM_KEY_VAR_NAME] }}
AWS_SECRET_ACCESS_KEY: ${{ secrets[env.TERRAFORM_SECRET_VAR_NAME] }}
TF_VAR_local_state_passphrase: ${{ secrets.TF_LOCAL_STATE_ENCRYPTION_KEY_2025_07 }}
TF_VAR_shared_state_passphrase: ${{ secrets.TF_SHARED_RESOURCES_STATE_ENCRYPTION_KEY_2025_07 }}
run: |
cd iac/${BRANCH_NAME}
ARN=$(tofu output -no-color -raw scratch_efs_arn)
aws lambda update-function-configuration --function-name vapor-your-app-${BRANCH_NAME} --file-system-configs Arn=$ARN,LocalMountPath=/mnt/scratch --region us-east-2
aws lambda update-function-configuration --function-name vapor-your-app-${BRANCH_NAME}-cli --file-system-configs Arn=$ARN,LocalMountPath=/mnt/scratch --region us-east-2
aws lambda update-function-configuration --function-name vapor-your-app-${BRANCH_NAME}-queue --file-system-configs Arn=$ARN,LocalMountPath=/mnt/scratch --region us-east-2
id: YOUR_PROJECT_ID
name: your-app
ignore:
- iac/
- docs/
- cypress/
environments:
develop:
memory: 4096
timeout: 28
cli-memory: 2048
queue-memory: 2048
queue-timeout: 840
cli-timeout: 840
queue-concurrency: 30
runtime: "php-8.4:al2"
domain:
- dev.your-app.example.edu
subnets:
- subnet-abc123
- subnet-def456
security-groups:
- sg-xyz890
queue-database-session-persist: true
build:
- "COMPOSER_MIRROR_PATH_REPOS=1 composer install --no-dev"
- "php artisan event:cache"
- "pnpm install && pnpm build && rm -rf node_modules"
deploy:
- "php artisan db:wake"
- "php artisan migrate --seed --force"
- "php artisan db:seed --class=StakeholderSeeder"

Optional: Attaching EFS for Persistent Scratch Storage

Section titled “Optional: Attaching EFS for Persistent Scratch Storage”

If your application needs a shared scratch disk for exports, temporary reports, or other short-lived files, attach an EFS file system to the Vapor Lambda functions:

Terminal window
aws lambda update-function-configuration \
--function-name vapor-your-app-${BRANCH_NAME} \
--file-system-configs Arn=$EFS_ARN,LocalMountPath=/mnt/scratch \
--region us-east-2
aws lambda update-function-configuration \
--function-name vapor-your-app-${BRANCH_NAME}-cli \
--file-system-configs Arn=$EFS_ARN,LocalMountPath=/mnt/scratch \
--region us-east-2
aws lambda update-function-configuration \
--function-name vapor-your-app-${BRANCH_NAME}-queue \
--file-system-configs Arn=$EFS_ARN,LocalMountPath=/mnt/scratch \
--region us-east-2