I was working on a bilingual limo booking platform for a client in Abu Dhabi last year. Deployments were a mess — manually copying files, praying migrations ran in order, and hoping environment variables didn’t get scrambled. It took 6 hours to deploy updates once — 6 hours of my day, just watching terminal logs scroll. That’s when I decided to bite the bullet and build a proper CI/CD pipeline using GitHub Actions. This post walks through how I set it up, the parts that went smoothly, and the stuff that made me swear at 2 a.m.
Why GitHub Actions Works for Mixed Tech Stacks
If your app has both a Next.js frontend and a Laravel backend (like Tawasul Limo did), orchestrating deployments can get messy fast. GitHub Actions lets you define jobs in YAML, runs them in virtual environments, and integrates directly with your repo. You don’t need Jenkins expertise to get basic workflows working.
Here’s the core of what I built:
name: Laravel & Next.js Deployment
on:
push:
branches: ["main"]
jobs:
build_frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install && npm run build
build_backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: shivammathur/setup-php@v2
with:
php-version: 8.2
- run: composer installThe frontend and backend jobs run in parallel. I used separate jobs because I deploy them to different servers — Vercel for Next.js, AWS EC2 for Laravel.
Running Laravel Migrations Automatically (Without Bricking Production)
The first time I tried this, I forgot to include a database backup step. Let me tell you — restoring a production database from a Laravel backup makes you break out in a cold sweat. Now I include this in the workflow:
- name: Pull latest DB backup
run: |
ssh user@production-server "mysqldump -u dbuser -pdbpassword dbname > /tmp/db.sql"
scp user@production-server:/tmp/db.sql ./
- name: Run migrations
run: php artisan migrate --forceThe --force flag feels reckless, but it’s necessary for non-interactive environments. I test migration diffs locally using Laravel’s Schema::getColumnListing() to catch breaking changes. For Postgres, I’ve switched to Neon in most UAE projects since they offer branching for safer migration testing — I wrote more about that here.
Handling Environment Variables Without Screwing Up
I learned the hard way that hardcoding staging variables in workflow files leads to production secrets being exposed. Instead, I use GitHub Secrets + .env files with placeholders:
- name: Create .env file
run: |
echo "APP_URL=${{ secrets.APP_PROD_URL }}" >> .env
echo "DB_PASSWORD=${{ secrets.DB_PROD_PASSWORD }}" >> .envThen in Laravel, I use str_replace() in the AppServiceProvider to dynamically inject secrets during deployment. For Next.js API routes, I pass them via NEXT_PUBLIC_ variables scoped to the Vercel environment — this article has a good example of how I restrict access.
Notifications That (Mostly) Work
I pipe deployment logs to Slack so clients can watch progress themselves. The initial setup used curl POSTs to a Slack webhook, but formatting was a nightmare. Now I format logs like this:
curl -X POST -H 'Content-type: application/json' \
--data '{"attachments": [{"pretext": "Deployment status: '`$STATUS`'", "text": "'"```"$LOGS"'"```"}]}' \
$SLACK_WEBHOOK_URLIt’s clunky, but it works. I tried using GitHub Actions’ own UI for status checks, but clients without dev background found the green checkmarks confusing — plain Slack messages get better engagement.
The Time This Broke Because Of A Merge Conflict
Two days before a client demo, I merged a Laravel upgrade branch into main, and the CI pipeline deployed the wrong code version. Turns out the workflow’s on.push.branches was set to ["develop"] in one project, ["main"] in another, and I forgot to check. Fixed it by adding branch validation to the workflow:
jobs:
validate_branch:
runs-on: ubuntu-latest
steps:
- run: |
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "Only main branch is deployable"
exit 1
fiNow GitHub Actions blocks deploys from non-main branches unless I explicitly override it with a manual trigger.
My Complete Pipeline Today
Here’s what the final workflow file looks like (simplified for readability):
name: Full Deployment
on:
push:
branches: ["main"]
jobs:
build_frontend:
runs-on: ubuntu-latest
steps: [...] # Node setup + Vercel deploy
build_backend:
runs-on: ubuntu-latest
steps: [...] # PHP setup + DB backup + migration + SSH deploy
send_notification:
needs: [build_frontend, build_backend]
runs-on: ubuntu-latest
steps:
- name: Send deployment success to Slack
run: curl -X POST [...] I run PHPUnit and ESLint tests in parallel before deployment, but I turned off strict linting rules mid-project because the backend team was using single quotes in .blade.php files and I couldn’t find time to standardize everything. Imperfection is part of real systems.
Frequently Asked Questions
How do I set up GitHub Actions for a Laravel+Next.js monorepo?
Use separate workflows, one triggering the other via workflow_dispatch. Store the frontend and backend in subdirectories. For shared config files, create a script that clones them into both directories before installing dependencies.
Can I run Laravel Mix in GitHub Actions?
Yes, but install Node.js first (setup-node@v3) and increase the timeout. I’ve hit 30-minute build times before because of bloated node_modules packages — pruning dependencies helped.
How do I handle database migrations for Laravel in GitHub Actions?
Use the php artisan migrate command inside the GitHub Actions runner. Test migrations locally in a Docker container that mirrors your production environment, otherwise you’ll hit path issues.
How secure is storing database credentials in GitHub Actions?
Use encrypted secrets and rotate credentials regularly. I disable Laravel debug mode in pipeline deployments to prevent accidental leaks, even if it makes debugging harder.
Got stuck on a pipeline problem? Let’s book a 30-minute session when I’ll walk through your setup and yell at your YAML files until they work.