Skip to content

Deployment

The starter does not prescribe a deployment platform. Northwestern teams use AWS-native, container-based, and serverless architectures depending on application needs. The most common 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.

Vapor runs the scheduler and queue workers for you. On any other target, you run them yourself:

  • php artisan schedule:run every minute, from cron or an equivalent
  • A long-running php artisan queue:work process, kept alive by a process supervisor

The scheduler (routes/console.php) runs retention pruning (model:prune), Telescope and temporary S3 file cleanup, the health checks and their heartbeat, OAuth token purging and MCP client cleanup, revoking credentials people can no longer hold, the client secret and personal access token reminders, and announcement notifications. Without it, expired records are never deleted, reminders and scheduled announcement notifications never go out, and the health check reports that the schedule has not run. The full list is in Scheduled Tasks.

Passport signs access tokens with an RSA key pair, which every deployed environment needs before the API, personal access tokens, applications or MCP can issue a token. Give each environment its own pair:

  1. Generate one in a temporary directory, outside the project:

    Terminal window
    cd "$(mktemp -d)"
    openssl genrsa -out oauth-private.key 4096
    openssl rsa -in oauth-private.key -pubout -out oauth-public.key

    Don’t use php artisan passport:keys --force for this. It overwrites your own storage/oauth-*.key, which signs out every client connected to your local application, and leaves the environment’s private key on your machine as your local one.

  2. Put the files’ contents in PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY in that environment’s encrypted .env.<environment> or secrets. In a .env file, wrap each key in double quotes and keep its line breaks.

  3. Delete the temporary directory. The environment’s private key now lives only in its secrets.

  4. Every instance of the environment uses the same pair. Replacing it invalidates every outstanding access token; see API Operations.

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 with queues, scheduled tasks, and CLI operations

Vapor reduces maintenance overhead for 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. Install the Vapor packages

    The starter does not include Vapor’s packages. Add the runtime as a production dependency and the CLI as a development dependency:

    Terminal window
    composer require laravel/vapor-core
    composer require --dev laravel/vapor-cli

    The CLI provides vendor/bin/vapor, used in the steps below and in the deployment workflow.

  2. Create the Vapor project

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

  3. 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
  4. Validate certificates via DNS

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

  5. Request VPC subnet allocations

    Obtain subnet IDs from your cloud/networking team.

  6. Configure vapor.yml

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

  7. 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: 8.5
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@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 12.x.x
- name: 🟢 Setup NodeJS
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 26
cache: pnpm
- name: 🌱 Setup OpenTofu
uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2
with:
tofu_version: 1.12.6
- 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/
environments:
develop:
memory: 4096
timeout: 28
cli-memory: 2048
queue-memory: 2048
queue-timeout: 840
cli-timeout: 840
queue-concurrency: 30
runtime: "php-8.5:al2023"
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