I realized at some point that I was still using bash scripts to push code to production. A simple push-code.sh file that I would adjust project by project. I use CI/CD pipelines daily at work, but for my personal projects, I just hadn’t considered setting them up. Until now.

The Starting Point

I have several personal projects running on AWS: Laravel applications, WordPress sites with custom themes and plugins, and a serverless Laravel app on Lambda. Each one was deployed manually with variations of the same bash script. Time to fix that.

The Architecture

The key insight was creating reusable workflows. Instead of duplicating pipeline code across repositories, I created centralized workflow templates that each project can call with its specific parameters.

aws-shared-hosting/
└── .github/workflows/
├── deploy-laravel.yml # EC2/Docker deployments
├── deploy-wp-theme.yml # WordPress themes
└── deploy-wp-plugin.yml # WordPress plugins

aws-serverless-laravel/
└── .github/workflows/
└── deploy-lambda.yml # Lambda deployments

Each project repository only needs a small caller workflow:

name: Deploy

on:
push:
branches: [main]
workflow_dispatch:

jobs:
deploy:
uses: username/aws-shared-hosting/.github/workflows/deploy-laravel.yml@main
with:
app_name: my-app
ec2_instance_id: ${{ vars.EC2_INSTANCE_ID }}
deploy_bucket: my-deploy-bucket
secrets: inherit

The Workflow Differences

Each project type has different deployment needs:

TypeKey Steps
LaravelComposer install, migrations, cache clear, queue restart
WordPress ThemeOptional npm build, rsync to themes folder, wp cache flush
WordPress PluginOptional composer for autoloader, rsync to plugins folder
LambdaComposer install, zip package, update Lambda functions, run migrations via invoke

The workflows handle these differences through conditional steps:

- name: Build frontend
if: ${{ inputs.build_frontend }}
run: |
npm ci
npm run build

The AWS Integration

GitHub Actions needs permissions to deploy. I created an IAM user via Terraform with specific permissions:

  • S3: Upload deployment artifacts to a temporary bucket
  • SSM: Send commands to EC2 (no SSH needed, more secure)
  • Lambda: Update function code and invoke for migrations

The S3 bucket has a lifecycle policy that auto-deletes artifacts after 1 day, so no cleanup is needed.

resource "aws_s3_bucket_lifecycle_configuration" "deploy" {
bucket = aws_s3_bucket.deploy.id

rule {
id = "cleanup-old-artifacts"
status = "Enabled"
filter {}
expiration {
days = 1
}
}
}

Using GitHub CLI, I set the AWS secrets on each repository:

gh secret set AWS_ACCESS_KEY_ID --repo username/my-project
gh secret set AWS_SECRET_ACCESS_KEY --repo username/my-project

Lessons Learned

A few things I discovered along the way:

  1. npm ci vs npm install: In CI/CD, always use npm ci: it’s faster and reads from the lockfile for reproducible builds.
  2. The autoloader trap: A WordPress plugin with only dev dependencies in composer.json still needs composer install --no-dev to generate the autoloader. Without it, classes won’t load.
  3. EBS mount paths: My Docker setup mounts EBS subdirectories directly, so the path is /mnt/ebs/site/themes/ not /mnt/ebs/site/wp-content/themes/. Knowing your infrastructure matters.

Results

ProjectDeploy Time
Laravel (no build)~23s
Laravel (with frontend)~1m 37s
WordPress Theme~24-39s
WordPress Plugin~27s
Lambda~48s

Every push to main now triggers an automatic deployment. No more SSH, no more scripts, no more forgetting which project needs which flags.

What’s Next

  • Static site deployments (S3 + CloudFront)
  • Consider OIDC instead of access keys for better security

The complete workflows are available in my GitHub repositories: