DEV Community

Magevanta
Magevanta

Posted on • Originally published at magevanta.com

Magento 2 CI/CD with GitHub Actions: A Production-Ready Deploy Pipeline

Deploying Magento 2 manually is a recipe for mistakes — missed setup:upgrade, forgotten cache flushes, or a static content deploy that never ran. GitHub Actions lets you automate every step, enforce consistency, and ship with confidence. In this guide we build a production-ready CI/CD pipeline from scratch.

Why GitHub Actions for Magento 2?

Magento deployments have a lot of moving parts:

  • Composer install (with auth.json for Magento repo)
  • Database schema + data migrations (setup:upgrade)
  • Dependency injection compilation (setup:di:compile)
  • Static content deploy (setup:static-content:deploy)
  • Cache flush
  • Potentially a maintenance window

One missed step can take your store down. Automating it removes human error and makes every deployment reproducible.

Repository Structure

Before setting up the pipeline, your repo should follow this layout:

magento-root/
├── app/
├── composer.json
├── composer.lock
├── .github/
│   └── workflows/
│       └── deploy.yml
└── ...
Enter fullscreen mode Exit fullscreen mode

Keep auth.json out of version control — we'll inject it via secrets.

Setting Up GitHub Secrets

Go to your repo → Settings → Secrets and variables → Actions and add:

Secret Value
SSH_PRIVATE_KEY Private key for your server
SSH_HOST Server IP or hostname
SSH_USER Deploy user (e.g. magento)
MAGENTO_REPO_PUBLIC Magento Marketplace public key
MAGENTO_REPO_PRIVATE Magento Marketplace private key
DEPLOY_PATH Absolute path on server (e.g. /var/www/magento)

The Workflow File

Create .github/workflows/deploy.yml:

name: Deploy Magento 2

on:
  push:
    branches:
      - main

jobs:
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: bcmath, ctype, curl, dom, gd, intl, mbstring, openssl, pdo_mysql, simplexml, soap, xsl, zip
          tools: composer:v2

      - name: Configure Magento Composer auth
        run: |
          composer config -g http-basic.repo.magento.com \
            ${{ secrets.MAGENTO_REPO_PUBLIC }} \
            ${{ secrets.MAGENTO_REPO_PRIVATE }}

      - name: Install dependencies
        run: composer install --no-dev --optimize-autoloader --no-interaction

      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add server to known hosts
        run: |
          ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Sync files to server
        run: |
          rsync -az --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='var/' \
            --exclude='pub/media/' \
            --exclude='app/etc/env.php' \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.DEPLOY_PATH }}/

      - name: Run Magento deploy commands
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
            set -e
            cd ${{ secrets.DEPLOY_PATH }}

            # Enable maintenance mode
            php bin/magento maintenance:enable

            # Run upgrades
            php bin/magento setup:upgrade --keep-generated

            # Compile DI
            php bin/magento setup:di:compile

            # Deploy static content (adjust locales as needed)
            php bin/magento setup:static-content:deploy en_US nl_NL -f

            # Flush cache
            php bin/magento cache:flush

            # Disable maintenance
            php bin/magento maintenance:disable

            echo "Deploy complete ✓"
          EOF
Enter fullscreen mode Exit fullscreen mode

Optimizing the Pipeline

Cache Composer Dependencies

Composer installs can take several minutes. Use GitHub's cache action to speed things up:

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-
Enter fullscreen mode Exit fullscreen mode

Add this step before composer install. On cache hit, installs drop from ~4 minutes to under 30 seconds.

Parallel Static Content Deploy

For stores with multiple locales, parallelize the static content deploy:

php bin/magento setup:static-content:deploy en_US nl_NL de_DE fr_FR -f --jobs=$(nproc)
Enter fullscreen mode Exit fullscreen mode

The --jobs flag uses multiple CPU cores. On a 4-core server this roughly cuts deploy time in half.

Skip Compilation When Unnecessary

If your push only changes templates or CMS content, running setup:di:compile wastes 3–5 minutes. Use path filters:

on:
  push:
    branches:
      - main
    paths-ignore:
      - 'app/design/**'
      - '**.md'
Enter fullscreen mode Exit fullscreen mode

Or use a more granular approach with conditional steps:

      - name: Check if PHP changed
        id: php_changed
        run: |
          git diff --name-only HEAD~1 HEAD | grep -qE '\.php$' && echo "changed=true" >> $GITHUB_OUTPUT || echo "changed=false" >> $GITHUB_OUTPUT

      - name: Compile DI
        if: steps.php_changed.outputs.changed == 'true'
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
            "cd ${{ secrets.DEPLOY_PATH }} && php bin/magento setup:di:compile"
Enter fullscreen mode Exit fullscreen mode

Zero-Downtime with Symlink Strategy

The maintenance window approach works, but causes downtime. A symlink-based blue/green strategy eliminates it:

/var/www/
├── releases/
│   ├── 20260426_110000/   ← new release
│   └── 20260420_090000/   ← previous
├── shared/
│   ├── env.php
│   ├── var/
│   └── pub/media/
└── current -> releases/20260426_110000  ← symlink, Nginx points here
Enter fullscreen mode Exit fullscreen mode

Update your deploy step to:

      - name: Deploy with symlink swap
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
            set -e
            RELEASE_DIR="${{ secrets.DEPLOY_PATH }}/releases/$(date +%Y%m%d_%H%M%S)"
            SHARED_DIR="${{ secrets.DEPLOY_PATH }}/shared"
            CURRENT="${{ secrets.DEPLOY_PATH }}/current"

            mkdir -p $RELEASE_DIR

            # Copy files (already rsynced to a staging dir in previous step)
            rsync -a ${{ secrets.DEPLOY_PATH }}/staging/ $RELEASE_DIR/

            # Link shared files
            ln -sfn $SHARED_DIR/env.php $RELEASE_DIR/app/etc/env.php
            ln -sfn $SHARED_DIR/var $RELEASE_DIR/var
            ln -sfn $SHARED_DIR/pub/media $RELEASE_DIR/pub/media

            cd $RELEASE_DIR
            php bin/magento setup:upgrade --keep-generated
            php bin/magento setup:di:compile
            php bin/magento setup:static-content:deploy en_US -f
            php bin/magento cache:flush

            # Atomic swap — no downtime
            ln -sfn $RELEASE_DIR $CURRENT

            # Keep last 5 releases
            ls -dt ${{ secrets.DEPLOY_PATH }}/releases/*/ | tail -n +6 | xargs rm -rf
          EOF
Enter fullscreen mode Exit fullscreen mode

Nginx points to /var/www/current and the symlink swap is atomic — visitors never see a broken state.

Rollback

With the releases directory, rollback is trivial:

# List releases
ls /var/www/releases/

# Roll back to previous
ln -sfn /var/www/releases/20260420_090000 /var/www/current
php bin/magento cache:flush
Enter fullscreen mode Exit fullscreen mode

Add a manual rollback workflow in .github/workflows/rollback.yml with workflow_dispatch trigger so you can trigger it from the GitHub UI with one click.

Notifications

Add a Slack or Telegram notification at the end of the job:

      - name: Notify on success
        if: success()
        run: |
          curl -s -X POST https://api.telegram.org/bot${{ secrets.TG_BOT_TOKEN }}/sendMessage \
            -d chat_id=${{ secrets.TG_CHAT_ID }} \
            -d text="✅ Magento deploy succeeded on $(date)"

      - name: Notify on failure
        if: failure()
        run: |
          curl -s -X POST https://api.telegram.org/bot${{ secrets.TG_BOT_TOKEN }}/sendMessage \
            -d chat_id=${{ secrets.TG_CHAT_ID }} \
            -d text="❌ Magento deploy FAILED — check GitHub Actions"
Enter fullscreen mode Exit fullscreen mode

Security Considerations

  • Never commit auth.json or app/etc/env.php. Add them to .gitignore.
  • Use a dedicated deploy user on the server with minimal permissions — no sudo, only write access to the web root.
  • Rotate SSH keys periodically and use GitHub's secret rotation reminders.
  • Consider branch protection rules: require PRs to merge into main, so deploys always go through review.

Complete Pipeline at a Glance

Step Time (estimate)
Checkout + PHP setup ~20s
Composer install (cached) ~30s
Rsync to server ~15s
setup:upgrade ~30s
setup:di:compile ~90s
Static content deploy ~60s
Cache flush ~5s
Total ~4 minutes

Four minutes from git push to live — with zero manual steps and full rollback capability.

Summary

A solid Magento 2 CI/CD pipeline with GitHub Actions gives you:

  • Repeatability — every deploy follows the same steps
  • Speed — cached dependencies and parallel jobs
  • Safety — atomic symlink swaps eliminate downtime
  • Observability — notifications on every deploy
  • Recovery — instant rollback to any previous release

Start with the basic workflow, then layer in the symlink strategy and parallelization as your store grows. Your future self will thank you at 2 AM when a deploy goes sideways and rollback takes 10 seconds instead of 45 minutes.

Top comments (0)