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
└── ...
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
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-
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)
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'
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"
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
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
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
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"
Security Considerations
-
Never commit
auth.jsonorapp/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)