Magento 2 CI/CD Pipeline with GitHub Actions
Many Magento stores still deploy by connecting to the server, running git pull, then composer install, setup:upgrade, setup:di:compile and setup:static-content:deploy in production. It works, but the site is broken or in maintenance for minutes, a failed step leaves it half updated, and nothing checks the code before it gets there.
A pipeline in GitHub Actions fixes both sides. Checks run on every pull request, the build happens on a CI runner, and the server only receives a finished release. This article shows a practical setup for Magento 2.4.8 with PHP 8.3. Adjust names and paths to your project.
The shape of the pipeline
I split the work into three jobs:
- Checks on every push and pull request: Composer validation, coding standard, PHPStan and unit tests.
- Build on the main branch: install dependencies without dev packages, compile DI, deploy static content, and package the result.
- Deploy: upload the package to the server as a new release, run database upgrades only if needed, switch a symlink, and clear caches.
The deploy job depends on the build job, and the build job depends on the checks. A failure anywhere stops the release.
Composer authentication
Magento Open Source and Adobe Commerce packages come from repo.magento.com, which requires access keys. Store them as a repository secret named COMPOSER_AUTH with this content:
{
"http-basic": {
"repo.magento.com": {
"username": "your-public-key",
"password": "your-private-key"
}
}
}
Composer reads the COMPOSER_AUTH environment variable automatically, so there is no need to write an auth.json on the runner. If you use private extension repositories, add their credentials to the same JSON.
The checks job
name: magento
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: bcmath, ctype, curl, dom, gd, intl, mbstring, pdo_mysql, soap, sockets, sodium, xsl, zip
tools: composer:v2
coverage: none
- uses: actions/cache@v4
with:
path: ~/.cache/composer
key: composer-${{ hashFiles('composer.lock') }}
- run: composer validate --no-check-publish
- run: composer install --no-interaction --no-progress
- name: Coding standard
run: vendor/bin/phpcs --standard=Magento2 app/code
- name: PHPStan
run: vendor/bin/phpstan analyse -c phpstan.neon
- name: Unit tests
run: vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code
Run the coding standard and PHPStan on app/code and on your own Composer packages, not on all of vendor. Checking core code produces thousands of messages that nobody will act on. The setup of each tool is covered in the PHPCS article, the PHPStan article and the PHPUnit article on this blog.
The unit test configuration file shipped by Magento is dev/tests/unit/phpunit.xml.dist. Many projects keep their own copy that only lists custom code, which is faster and avoids running core tests.
Building without a database
Two commands produce most of what a production Magento needs: setup:di:compile and setup:static-content:deploy. Neither needs a database, as long as the right configuration is in the repository.
setup:di:compile reads the module list from app/etc/config.php. Static content deploy also needs to know which themes and locales exist. Without a database it gets that from config.php too, after you export it once from a working environment:
bin/magento app:config:dump scopes themes
This writes websites, stores and themes into config.php. Commit the result. From then on, the build can run anywhere. The env.php and config.php article explains why this belongs in config.php and not in env.php.
The build job
build:
needs: checks
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: bcmath, ctype, curl, dom, gd, intl, mbstring, pdo_mysql, soap, sockets, sodium, xsl, zip
tools: composer:v2
coverage: none
- run: composer install --no-dev --no-interaction --no-progress --optimize-autoloader
- run: bin/magento setup:di:compile
- run: bin/magento setup:static-content:deploy en_US pt_BR -f --jobs=4
- run: composer dump-autoload --no-dev --optimize --apcu
- name: Package release
run: |
tar -czf release.tar.gz \
--exclude=.git \
--exclude=var \
--exclude=pub/media \
--exclude=app/etc/env.php \
.
- uses: actions/upload-artifact@v4
with:
name: release
path: release.tar.gz
List the locales your stores actually use. The -f flag is needed when the runner is not in production mode, which it is not, since there is no env.php. Remove --apcu if your PHP-FPM does not have APCu enabled. Static content deploy is usually the slowest step of the build, so deploy only the themes you use with --theme if the project has unused ones.
The deploy job
On the server, use a directory layout with releases and shared files:
/var/www/shop/
releases/
20261110120000/
20261109153000/
shared/
app/etc/env.php
pub/media/
var/
current -> releases/20261110120000
The web server document root points to current/pub. Each deploy unpacks into a new folder, links the shared files, and only then moves the current symlink. The job:
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: release
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Upload and activate
env:
HOST: ${{ secrets.DEPLOY_HOST }}
run: |
RELEASE=$(date +%Y%m%d%H%M%S)
scp -o StrictHostKeyChecking=accept-new release.tar.gz deploy@$HOST:/tmp/release.tar.gz
ssh deploy@$HOST "bash /var/www/shop/deploy.sh $RELEASE"
Keep the server side logic in a script under version control instead of a long inline SSH command. A minimal deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
BASE=/var/www/shop
REL="$BASE/releases/$1"
mkdir -p "$REL"
tar -xzf /tmp/release.tar.gz -C "$REL"
rm /tmp/release.tar.gz
ln -sfn "$BASE/shared/app/etc/env.php" "$REL/app/etc/env.php"
ln -sfn "$BASE/shared/pub/media" "$REL/pub/media"
ln -sfn "$BASE/shared/var" "$REL/var"
cd "$REL"
if ! php bin/magento setup:db:status; then
php bin/magento maintenance:enable
php bin/magento setup:upgrade --keep-generated
fi
ln -sfn "$REL" "$BASE/current"
php bin/magento maintenance:disable || true
php bin/magento cache:flush
ls -1dt "$BASE"/releases/* | tail -n +6 | xargs -r rm -rf
setup:db:status returns a non zero exit code when schema or data patches are pending. Only then does the script enable maintenance and run setup:upgrade. The --keep-generated flag keeps the compiled code from the build. On deploys without database changes there is no maintenance window at all.
Two details are easy to miss. First, PHP OPcache may keep serving old files after the symlink moves. Reload PHP-FPM or use realpath aware OPcache settings so the new release is picked up. Second, a shared var folder means cache and generated metadata survive between releases, so flush caches after the switch, as above. If you use Varnish, purge it too.
A deeper discussion of maintenance mode, database changes that are safe to run before the switch and rollback is in zero downtime deploys for Magento 2.
Cron and queue consumers
Cron entries and process managers must point to current, never to a specific release folder. Otherwise they keep running old code after the switch, or fail once the old release is deleted. Use a crontab line that runs /var/www/shop/current/bin/magento cron:run, and configure supervisor or systemd consumers the same way.
Long running queue consumers also hold the old code in memory. After a deploy, restart them so they load the new release. If consumers are started by cron with a limit on messages, they will exit and restart on their own, but a restart in the deploy script makes the timing predictable. The same applies to any PHP worker that stays alive between requests.
What to add later
Once the basic pipeline is stable, it is easy to grow. Integration tests can run in a job with MySQL and OpenSearch as service containers. A smoke test after deploy can request the home page, a category page and the cart and fail the job if any of them returns an error. A notification step can post the release name and commit to the team chat. Add these one at a time, and keep the pipeline fast enough that people do not start skipping it.
Rolling back
Because old releases stay on disk, a rollback is moving the symlink back and flushing caches. That only works cleanly if the database changes in the bad release were backward compatible. When a release adds columns or tables, the old code usually ignores them. When it removes or renames them, the old code breaks. Plan schema changes in two steps when possible: add first, remove later in a separate release.
Secrets and environments
- Use GitHub environments with required reviewers for production, so a merge does not deploy without someone pressing a button.
- Give the deploy SSH key access to one user that can only write to the shop folder and reload PHP-FPM.
- Keep
env.phpon the server only. The pipeline never needs database passwords or encryption keys. - Run the same workflow against a staging environment first, with its own secrets.
FAQ
Does setup:di:compile need a database connection?
No. It needs the code and app/etc/config.php with the module list. That is why it can run in CI. Static content deploy also works without a database if themes and scopes are dumped into config.php with app:config:dump.
How do I give GitHub Actions access to repo.magento.com?
Store an auth.json style JSON with your Marketplace public and private keys as a repository secret and pass it to Composer in the COMPOSER_AUTH environment variable. Do not commit auth.json.
Should the pipeline run setup:upgrade on every deploy?
Only when it is needed. setup:db:status tells you whether schema or data patches are pending. If nothing changed, you can skip maintenance mode and setup:upgrade completely.
Can I build on the production server instead?
You can, but it competes for CPU with live traffic and leaves the site in a broken state if the build fails halfway. Building in CI and shipping a finished release avoids both problems.
I am available for freelance Magento and Adobe Commerce work, including build and deployment pipelines. If you need help with this, get in touch.
Related articles
Zero Downtime Magento 2 Deploys on a Single Server
A practical zero downtime deploy for Magento 2.4 on one server: build in a release folder, compile outside live, switch a symlink and roll back in seconds.
Magento 2 Production Mode: Switching and Deploying
What Magento 2 production mode changes, how to switch safely and a deploy sequence for 2.4.8 that moves compilation and static deploy out of downtime.
Magento 2 Backup and Restore: What to Save and How
How to back up and restore a Magento 2 store: database dumps that import cleanly, media, env.php and the encryption key, offsite copies and tested restores.
Magento 2 Coding Standard with PHPCS: Setup and Usage
Set up the Magento 2 coding standard with PHP_CodeSniffer: the Magento2 ruleset, a project phpcs.xml, severity levels, phpcbf and checking changed files.