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:
| Type | Key Steps |
|---|---|
| Laravel | Composer install, migrations, cache clear, queue restart |
| WordPress Theme | Optional npm build, rsync to themes folder, wp cache flush |
| WordPress Plugin | Optional composer for autoloader, rsync to plugins folder |
| Lambda | Composer 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:
npm civsnpm install: In CI/CD, always usenpm ci: it’s faster and reads from the lockfile for reproducible builds.- The autoloader trap: A WordPress plugin with only dev dependencies in
composer.jsonstill needscomposer install --no-devto generate the autoloader. Without it, classes won’t load. - 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
| Project | Deploy 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:
- aws-shared-hosting – EC2/Docker infrastructure + CI/CD
- aws-serverless-laravel – Lambda infrastructure + CI/CD