Structuring GitHub Workflows for Simple, Reliable Deployments

Structuring GitHub Workflows for Simple, Reliable Deployments

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

  1. Copy the workflow to .github/workflows/deploy-production.yml
  2. Update the environment variables for your application
  3. Configure your secrets (API tokens, server identifiers)
  4. Test with a manual deployment first
  5. 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.

profile image of Coderbuds Team

Coderbuds Team

The Coderbuds team writes about DORA metrics, engineering velocity, and software delivery performance to help development teams improve their processes.

More posts from Coderbuds Team