Okay… where do I start…
I have been kind of running away from AWS for a while now. Not because I hate it; I just felt it had so much going on, and I usually find myself involved more in building applications than setting up cloud infrastructures (until recently). So I thought, as I am practically getting my hands dirty, why not also walk you through what I am doing myself? And yes, also have something as a reference too. 😀
Why does this guide exist?
First, this is basically to show you my thought process of how I manage monolithic applications with a small team, especially for staging environments.
And also... most “deploy Laravel to AWS” tutorials show you a happy path. I feel like they don’t really tell you about the silent billing plan block that kills your EC2 launch, the Nginx document root trap, the security group CIDR gotcha that times out your database, or the PHP version mismatch that breaks Composer mid-deploy.
Oh yes, and this goated tweet here on a lighter note. 😂
One more meme picture I saw on my good friend Elisha Ukpong’s status. 😂
At the end of this guide, you should have the following:
- A Laravel app deployed to AWS Elastic Beanstalk
- A GitHub Actions CI/CD pipeline using OIDC (no static AWS keys stored in GitHub)
- A managed RDS MySQL database
- Automatic migrations on every deploy
- SSH access for debugging And an estimated monthly cost of ~$15–25/mo (t2.micro EB instance + db.t4g.micro RDS), compared to ~$60–70/mo for an ECS Fargate + ALB + NAT setup.
What do I need to continue this guide?
- An AWS account
- A Laravel app on GitHub (we’ll make reference to a dev branch for staging)
- A domain (we’ll connect it at the end)
- AWS CLI and EB CLI installed locally If you don’t have the AWS CLI and EB CLI installed locally for Mac, you can type the command:
brew install awscli
brew install awsebcli
Please note, I am really going to take my time on this one. from setup to deployment, so there may be some things you may already know as someone who may have used or is using AWS. And heads up, this is going to be a longgggggg article.
Alright, let’s get started.
AWS Account Setup
Create an IAM Admin User
The first thing you need to have in mind is… never use your root AWS account for day-to-day work!!!. Instead, create a dedicated admin user:
How do I do that?
- Go to your AWS Console and go to IAM (you can as well use the search bar to search for “IAM”)
- Click on "create group"; you can name it "admin" and attach "AdministratorAccess."
- Then in users, create a new user, maybe something like [your-name-admin], and then add it to the admins group
- On the security credentials tab, enable MFA for the user
Upgrade Your AWS Account to Pay-As-You-Go
This is one of the most commonly missed steps. New AWS accounts are sometimes placed on a “Free Plan” that silently blocks EC2 instance launches (even t2.micro) with cryptic errors like “instance type not eligible for Free Tier.” I had this issue too.
How do you fix this? In the AWS Console, go to Billing and Cost Management and look for the upgrade plan button. Click it and confirm.
Without this, Elastic Beanstalk environments may fail to launch with no clear error message.
OIDC Authentication for GitHub Actions
Instead of storing AWS access keys as GitHub secrets, which is a security anti-pattern, we use OpenID Connect (OIDC). GitHub’s CI runner proves its identity to AWS directly; no long-lived credentials are needed.
Adding GitHub as an OIDC Identity Provider
Head over to IAM in the AWS console and select Identity providers. Click Add provider.
Select OpenID Connect as the provider type.
token.actions.githubusercontent.com as the provider URL
and sts.amazonaws.com as the audience
Press enter
Create the GitHub Actions IAM role.
Still on the IAM page, click on “Roles” under Access Management. Click on the Create Role button.
Here is the information you will need.
- Trusted entity type: Web Identity
- Identity Provider: token.actions.githubusercontent.com
- Audience: sts.amazonaws.com
- Attach policy: AdministratorAccess (you can scope this down to least privilege once you're stable).
After creation, you may need to edit the trust policy to scope it to your specific repository. See code below:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:YOUR_ORG/YOUR_REPO:*"
}
}
}
]
}
Copy the role ARN; you’ll need to add this to GitHub Secrets as AWS_ROLE_ARN.
Up next? RDS MySQL Database 😆
Let's create this database.
On the AWS console, search for RDS, enter RDS, and click "Select Database."
Now fill in the following details.
Engine: MySQL
Template: Dev/Test (this disables Multi-AZ and saves cost)
DB instance class: db.t4g.micro
Storage: 20 GB gp3
VPC: default VPC
Public access: No (the app connects internally; never!) ever! expose RDS to the internet)
Create a new security group: [your-app]-rds-sg
Initial database name: [your_app]_staging
You will need to take note of the endpoint hostname (an example is your-db.xxxxx.us-east-1.rds.amazonaws.com), port (usually 3306), username, and password.
Let's talk about the security group too (I think this is a critical detail for me).
The RDS security group needs an inbound rule allowing MySQL (port 3306) from the EC2 security group that Elastic Beanstalk will assign to your instance… not an IP address (CIDR).
A CIDR-based inbound rule (like 0.0.0.0/0 or “My IP”) will not work correctly for EB-to-RDS communication. You will get connection timeouts. The rule must reference the EC2 security group by its ID. We’ll come back to this after the EB environment is created.
Now to the main part… Elastic Beanstalk Setup 😁
Creating EB Service Roles
Before creating an EB environment, two IAM roles are needed:
The Service role (EB in itself):
Trusted entity elasticbeanstalk.amazonaws.com
Use case: Environment
Name: aws-elasticbeanstalk-service-role
EC2 instance role (your actual server):
Trusted entity: EC2
Use case: Elastic Beanstalk-Compute
Name: aws-elasticbeanstalk-ec2-role
Creating the EB Application and Environment
Application name: your-app-backend
Environment name: your-app-backend-staging
Platform: PHP (choose the PHP version matching your composer.json PHP constraint—check with php -v locally)
Instance type: t2.micro (this is free-eligible, about $8–10/month)
Single instance (no load balancer — this is what keeps costs low)
Set the Document Root
Just a quick notice….
Laravel’s entry point is public/index.php, not the project root. By default, EB’s nginx config serves from the project root, which causes a 403 Forbidden on every request.
You can fix this via the official EB setting (you don’t need any extra nginx configuration).
Add the Nginx rewrite rule.
Even with the document root set, Nginx needs a rewrite rule to send all non-file requests to index.php (Laravel’s front controller). Without this, every route except / returns 404.
Create this file in your repo:
.platform/nginx/conf.d/elasticbeanstalk/01_laravel.conf
with this code...
location / {
try_files $uri $uri/ /index.php?$query_string;
}
Commit and push this file to your deploy branch. This file is minimal by design: it only adds the rewrite and lets EB’s platform-managed config handle everything else (document root, PHP-FPM connection, and fastcgi params).
Set Environment Variables
Rather than shipping an .env file (insecure), inject all config as EB environment properties. The fastest way is via the EB CLI:
# If its first time setup, run from your project root
eb init # select us-east-1, select your application
eb use your-app-backend-staging
# Then set all variables in one command
eb setenv \
APP_ENV=staging \
APP_KEY=base64:your-generated-key \
APP_DEBUG=false \
APP_URL=https://staging-api.yourdomain.com \
DB_CONNECTION=mysql \
DB_HOST=your-db.xxxxx.us-east-1.rds.amazonaws.com \
DB_PORT=3306 \
DB_DATABASE=your_app_staging \
DB_USERNAME=your_db_user \
DB_PASSWORD=your_db_password \
CACHE_DRIVER=file \
SESSION_DRIVER=file \
QUEUE_CONNECTION=sync \
LOG_CHANNEL=stack \
# ... add all provider keys here
Some gotchas...
EB has a ~4096 character limit on the combined environment variable payload passed through CloudFormation. If you have very long values (e.g. a JWT token over 2000 characters), eb setenv will fail with the template format error: Parameter ‘EnvironmentVariables’ default value length is greater than 4096. Drop the oversized variable and add it separately via AWS Secrets Manager or SSM Parameter Store instead.
GitHub Actions CI/CD Pipeline
Create .github/workflows/deploy-staging.yml:
name: Deploy to AWS Staging
on:
push:
branches:
- dev
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Install PHP dependencies
run: |
composer install --no-dev --optimize-autoloader --no-interaction
- name: Create deployment package
run: |
zip -r deploy.zip . \
--exclude "*.git*" \
--exclude ".github/*" \
--exclude "node_modules/*" \
--exclude "tests/*" \
--exclude ".env*"
- name: Upload to S3
run: |
VERSION_LABEL="v-$(date +%Y%m%d%H%M%S)"
aws s3 cp deploy.zip s3://elasticbeanstalk-us-east-1-YOUR_ACCOUNT_ID/deploy.zip
echo "VERSION_LABEL=$VERSION_LABEL" >> $GITHUB_ENV
- name: Create EB application version
run: |
aws elasticbeanstalk create-application-version \
--application-name your-app-backend \
--version-label ${{ env.VERSION_LABEL }} \
--source-bundle S3Bucket=elasticbeanstalk-us-east-1-YOUR_ACCOUNT_ID,S3Key=deploy.zip
- name: Deploy to Elastic Beanstalk
run: |
aws elasticbeanstalk update-environment \
--environment-name your-app-backend-staging \
--version-label ${{ env.VERSION_LABEL }}
Add AWS_ROLE_ARN to your GitHub repository secrets (Settings → Secrets and variables → Actions).
Fix the RDS security group.
After your first successful deploy, the EC2 instance’s security group ID will be visible. You need to allow it in the RDS security group:
EB Console → Configuration → Instance traffic and scaling → Note the EC2 security group ID (format: sg-xxxxxxxxxx)
EC2 Console → Security Groups → Find your-app-rds-sg → Inbound Rules → Edit Inbound Rules
Now here’s where it gets tricky…
The natural instinct is to type your IP address as the source. Don’t do this.
CIDR-based inbound rules cause connection timeouts for EB-to-RDS communication. And when you try to switch an existing CIDR rule to a security group reference, AWS throws that error above. This is because the rule types are fundamentally different. You can’t convert one to the other in place.
The fix:
Delete the existing CIDR rule entirely
Click Add rule
Type: MySQL/Aurora, Port: 3306, Source: Custom → paste/select the EC2 security group ID
Save rules
Important: Every time your EB environment is recreated (e.g, when adding SSH access, changing platform version, or running eb ssh --setup), check this rule. The EC2 security group ID can change, and you'll get a Connection timed out error that looks completely unrelated to what you just did.
Running Migrations
EB doesn’t give you a terminal out of the box. To run php artisan commands, you need SSH access first:
eb ssh --setup
This terminates and recreates your EC2 instance. There will be a brief downtime on staging; it's completely fine.
Once connected, there’s another gotcha nobody tells you about: EB environment variables aren’t loaded in your SSH session. The web server process gets them injected automatically, but your interactive shell doesn’t. You’ll know this hit you when you run. Artisan and see:
SQLSTATE[HY000] [2002] Connection refused
Host: 127.0.0.1
That 127.0.0.1 is the giveaway… it's the local fallback, not your RDS endpoint.
Fix it by sourcing the EB env file manually every SSH session:
sudo cat /opt/elasticbeanstalk/deployment/env | sed 's/^/export /' > /tmp/eb_env.sh
source /tmp/eb_env.sh
Verify it worked:
php artisan tinker --execute="echo config('database.connections.mysql.host');"
# Should print your RDS endpoint, not 127.0.0.1
Then navigate to the app directory and run migrations:
cd /var/app/current
php artisan migrate --force
The --force flag is required because Laravel blocks migrations in production environments without explicit confirmation.
Automate Migrations on Every Deploy
You don’t want to SSH in every time you add a migration. EB supports post-deploy hooks—scripts that run automatically after every deploy. Create this file in your repo:
.platform/hooks/postdeploy/01_migrate.sh
#!/bin/bash
set -e
cd /var/app/current
if [ -f /opt/elasticbeanstalk/deployment/env ] ]; then
export $(cat /opt/elasticbeanstalk/deployment/env | sed 's/^export //' | xargs)
fi
php artisan migrate --force
Make it executable and commit:
chmod +x .platform/hooks/postdeploy/01_migrate.sh
git add -A
git commit -m "Add postdeploy hook to auto-run migrations"
git push origin dev
From this point on, every push dev automatically runs pending migrations after the new code deploys.
Connect Your Domain
If your frontend is on Vercel, your domain’s DNS is managed there and not in your domain registrar’s panel. Namecheap (or wherever you registered) is just the registrar. The actual DNS records need to go into Vercel Dashboard → your team → Domains. yourdomain.app → DNS Records:
NameTypeValueTTLstaging-api CNAME your-app-staging.eba-xxxxxxxx.us-east-1.elasticbeanstalk.com60
If a conflicting A record already exists for it, delete it first. A CNAME and A record cannot coexist on the same name. Vercel will show an error if you try.
Confirm DNS is propagated:
ping staging-api.yourdomain.com
The ping will always timeout: EC2 blocks ICMP by default. That’s normal. What matters is that it resolves to your EB instance IP address. If you see the IP, DNS is working.
Adding SSL with Let’s Encrypt
Since we’re running single-instance EB without a load balancer, we can’t use ACM certificates directly (those attach to ALB listeners). Instead, we use Certbot to get a free Let’s Encrypt certificate that runs on the instance itself.
Create .platform/hooks/postdeploy/02_certbot.sh:
#!/bin/bash
set -e
DOMAIN="staging-api.yourdomain.com"
EMAIL="your@email.com"
if ! command -v certbot &> /dev/null; then
dnf install -y certbot python3-certbot-nginx
fi
if certbot certificates 2>/dev/null | grep -q "$DOMAIN"; then
certbot renew --quiet --nginx
else
certbot --nginx \
--non-interactive \
--agree-tos \
--email "$EMAIL" \
--domains "$DOMAIN" \
--redirect
fi
Also open port 443 in the EB EC2 security group automatically. Create .ebextensions/https-sg.config:
Resources:
sslSecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId:
"Fn::GetAtt":
- AWSEBSecurityGroup
- GroupId
IpProtocol: tcp
ToPort: 443
FromPort: 443
CidrIp: 0.0.0.0/0
Use "Fn::GetAtt" with the list syntax, EB's YAML parser rejects the shorthand and gives you this error:
Invalid Yaml: could not determine a constructor for the tag !GetAtt
I spent more time on this than I’d like to admit. 😅
Make the hook executable and push:
chmod +x .platform/hooks/postdeploy/02_certbot.sh
git add -A
git commit -m "Add Let's Encrypt SSL via Certbot and open port 443"
git push origin dev
The DNS CNAME must be live and propagated before this hook runs. Certbot verifies domain ownership by making an HTTP request to your domain. If the domain doesn’t resolve to your server yet, the cert request will fail.
One more nginx gotcha (I promise this is the last one).
Even after Certbot successfully gets the cert, you might still see ERR_CONNECTION_REFUSED when hitting the HTTPS URL. This happened to me: Certbot installed the cert, but nginx wasn't actually listening on port 443.
Check it:
sudo ss -tlnp | grep -E '80|443'
If you only see port 80 and not 443, Nginx didn’t pick up Certbot’s SSL config. The fix is to manually create the HTTPS server block:
sudo tee /etc/nginx/conf.d/https.conf > /dev/null << 'EOF'
server {
listen 443 ssl;
server_name staging-api.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/staging-api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging-api.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/html/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.(php|phar)(/.*)?$ {
fastcgi_pass php-fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include /etc/nginx/fastcgi_params;
}
}
server {
listen 80;
server_name staging-api.yourdomain.com;
return 301 https://$host$request_uri;
}
EOF
sudo nginx -t && sudo systemctl reload nginx
Check if port 443 is now listening:
sudo ss -tlnp | grep -E '80|443'
# LISTEN 0 511 0.0.0.0:443 ← this is what you want to see
# LISTEN 0 511 0.0.0.0:80
That’s it. You’re live.
To recap what we built:
- Laravel app deployed to AWS Elastic Beanstalk (single instance, ~$15–25/mo)
- GitHub Actions CI/CD via OIDC: no static AWS keys stored anywhere
- RDS MySQL with proper security group configuration
- Automated migrations on every deploy via postdeploy hooks
- Free SSL via Let’s Encrypt/Certbot
- Custom domain connected
Common Errors and Fixes
I had the liberty to draft out a few errors that you may experience during this deployment process, and I have suggested some solutions too.
And yeah, I guess… we are done. 😂🎉
Whew!!! That was a long one, and I do hope you found one or two things helpful. I am also curious about how you handle your own deployments too with Docker and all the other approaches that aid development.
Happy Cost savingg!!
You can connect with me on Twitter: https://twitter.com/mabadejedanphp















Top comments (0)