After years of wrestling with complex deployment pipelines, I've learned that the best deployments are often the simplest ones. Today I want to share how we structure our GitHub Actions workflows for reliable, maintainable deployments—and why we chose simplicity over sophisticated tooling.
#The Problem with Complex Deployment Pipelines
Most teams start with simple deployments, then gradually add complexity:
- Multi-stage environments
- Complex approval processes
- Intricate rollback mechanisms
- Dozens of environment variables
- Custom deployment scripts that only one person understands
Before you know it, your deployment takes 45 minutes and breaks if someone sneezes. We've been there.
#Our Philosophy: Simple, Reliable, Manual When Needed
Here's the deployment workflow structure we use for our production applications:
1name: Deploy to Production
2
3on:
4 push:
5 branches: [mainline]
6 workflow_dispatch:
7 inputs:
8 reason:
9 description: "Why are we deploying?"
10 required: false
11 type: string
12 default: "Manual deployment"
13
14env:
15 APP_URL: https://coderbuds.com
16
17run-name: Deploy to Production • ${{ github.event_name == 'workflow_dispatch' && inputs.reason || 'push' }}
18
19permissions:
20 contents: read
21 deployments: write
22
23concurrency:
24 group: production
25 cancel-in-progress: false
26
27jobs:
28 deploy:
29 runs-on: ubuntu-latest
30 timeout-minutes: 45
31 environment:
32 name: production
33 url: ${{ env.APP_URL }}
34
35 steps:
36 - name: Note reason (manual runs)
37 if: ${{ github.event_name == 'workflow_dispatch' && inputs.reason != '' }}
38 run: |
39 echo "Reason: ${{ inputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
40
41 - name: Check out repository
42 uses: actions/checkout@v5
43
44 - name: Cache Composer packages
45 uses: actions/cache@v4
46 with:
47 path: ~/.composer/cache
48 key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
49 restore-keys: composer-${{ runner.os }}-
50
51 - name: Set up PHP & Composer
52 uses: shivammathur/setup-php@v2
53 with:
54 php-version: '8.3'
55 tools: composer
56
57 - name: Install Forge CLI
58 run: composer global require laravel/forge-cli
59
60 - name: Deploy Site
61 env:
62 FORGE_API_TOKEN: ${{ secrets.FORGE_API_TOKEN }}
63 run: |
64 forge server:switch ${{ secrets.FORGE_SERVER_IDENTIFIER }}
65 forge deploy ${{ secrets.FORGE_SITE_DOMAIN }}
66
67 - name: Smoke test site
68 run: curl -sSf ${{ env.APP_URL }} -o /dev/null
Let me break down why this structure works so well.
#Key Design Decisions
#1. Dual Trigger Strategy
1on:
2 push:
3 branches: [mainline]
4 workflow_dispatch:
5 inputs:
6 reason:
7 description: "Why are we deploying?"
8 required: false
9 type: string
10 default: "Manual deployment"
Automatic deployments happen on every push to mainline
. This encourages frequent, small deployments—a key practice of high-performing teams.
Manual deployments are available through GitHub's UI with an optional reason field. This is crucial for:
- Hotfixes that bypass normal flow
- Redeploying after infrastructure changes
- Emergency rollbacks
- Demonstrating features to stakeholders
#2. Clear Run Names
1run-name: Deploy to Production • ${{ github.event_name == 'workflow_dispatch' && inputs.reason || 'push' }}
When you're looking at deployment history, you want to immediately understand what triggered each deployment. Our run names show whether it was automatic or manual, and include the reason for manual deployments.
#3. Proper Concurrency Control
1concurrency:
2 group: production
3 cancel-in-progress: false
Only one production deployment can run at a time, and we never cancel in-progress deployments. This prevents race conditions and ensures deployments complete fully.
#4. Environment Protection
1environment:
2 name: production
3 url: ${{ env.APP_URL }}
Using GitHub Environments gives you:
- Deployment history and status
- Protection rules (if needed)
- Environment-specific secrets
- Clear visibility into what's deployed where
#The YAML Syntax Gotcha That Trips Everyone Up
Here's a common issue you'll encounter when building GitHub workflows:
1# This will fail with "Nested mappings are not allowed"
2run: echo "Reason: ${{ inputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
3
4# This works perfectly
5run: |
6 echo "Reason: ${{ inputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
GitHub expressions (${{ }}
) can conflict with YAML's compact mapping syntax. The solution is using block scalar syntax (|
) for any run commands that contain GitHub expressions.
#Why We Avoid curl for Deployment
Many teams use curl commands to trigger deployments on their servers. We avoid this approach because:
Security concerns: API tokens in workflows, webhook endpoints to secure
Error handling: curl succeeds even if your deployment fails
Debugging: Limited visibility into what actually happened
Dependencies: Requires maintaining custom deployment APIs
Instead, we use Laravel Forge CLI, which gives us:
- Proper error handling and exit codes
- Integration with Forge's deployment history
- Built-in rollback capabilities
- No custom code to maintain
#The Power of Smoke Tests
1- name: Smoke test site
2 run: curl -sSf ${{ env.APP_URL }} -o /dev/null
This simple smoke test catches ~80% of deployment issues:
- Site not responding
- 500 errors from broken migrations
- Configuration problems
- DNS/routing issues
The -sSf
flags mean:
-s
: Silent (no progress bar)-S
: Show errors even in silent mode-f
: Fail on HTTP errors (4xx, 5xx)
#Benefits of This Approach
#1. Predictable Deployments
Every deployment follows the same path. No special cases, no "this time we'll do it differently."
#2. Fast Feedback
From push to deployed: typically under 3 minutes. If something breaks, you know immediately.
#3. Manual Override Capability
Sometimes you need to deploy outside the normal flow. The workflow_dispatch
trigger makes this trivial.
#4. Audit Trail
Every deployment is tracked with timestamps, triggers, and reasons. This is invaluable for incident response.
#5. Easy Debugging
When deployments fail, the error is usually obvious. No complex deployment scripts to decipher.
#What We Learned the Hard Way
#Start Simple, Add Complexity Only When Needed
Our first deployment workflow was 200+ lines and took 15 minutes. It was "thorough" but broke constantly. This 30-line version deploys faster and fails less.
#Manual Deployments Are Not a Failure
Some teams feel like manual deployments are a step backward. They're wrong. Having the option to deploy manually when needed is a sign of mature operations.
#YAML Syntax Matters
GitHub workflows fail silently on YAML syntax errors. Always validate your YAML, especially around GitHub expressions.
#Concurrency Control Is Critical
We once had three deployments running simultaneously. The result was... not pretty. Always control concurrency for production deployments.
#Extending This Pattern
This basic structure works well for most applications. As you grow, you might add:
- Multi-environment deployments (staging → production)
- Database migration safety checks
- Feature flag toggles
- Slack/Discord notifications
- Performance monitoring integration
But start simple. Get this working reliably, then add complexity only when you have a specific need.
#The Real Benefits
The best deployment system is the one your team actually uses and trusts. This workflow structure gives us:
- Confidence: Deployments just work
- Speed: From code to production in minutes
- Flexibility: Manual deployments when needed
- Clarity: Everyone understands how deployments work
- Reliability: Simple systems break less
#Getting Started
- Copy the workflow to
.github/workflows/deploy-production.yml
- Update the environment variables for your application
- Configure your secrets (API tokens, server identifiers)
- Test with a manual deployment first
- Enable automatic deployments once you're confident
Remember: the goal isn't to have the most sophisticated deployment pipeline. The goal is to deploy frequently, reliably, and with confidence.
Your deployment workflow should be boring. And that's exactly what makes it powerful.