Build workflows and Dockerfile recipes for Magento 2, Next.js, GraphCommerce and Laravel projects on Deployyy.
Each git branch gets its own environment. Preview environments pause when idle. Your repository holds your code and two short caller workflows. Project settings live in the Deployyy console.
Install the Deployyy GitHub App on your repository: github.com/apps/deployyy-platform.
Then connect the repository to a project in the Deployyy console. If your GitHub organization has no Deployyy team yet, contact Deployyy support.
Keep every project variable and secret in GitHub, under the repository's Settings → Secrets and variables → Actions. Use secrets for keys and passwords. Use variables for values that are safe to read back, such as a public address or a feature flag.
Every value reaches the build. Magento's config.php reads them with
getenv() at build time.
- Per environment: create a GitHub Environment named after the branch,
such as
mainorstaging, under Settings → Environments. Its variables and secrets override the repository's for that branch. A branch without a GitHub Environment uses the repository's values. - Composer credentials: add a secret
COMPOSER_AUTHwith the contents of yourauth.json, for Magento Marketplace or Packagist keys. Mage-OS on public packages needs no credentials. - Changes apply with the next build of the branch. Push, or re-run the latest build. A redeploy or rollback without a build keeps the values of the last build.
The running application reads its variables from Deployyy: Environment
variables in the console, or deployyy env add.
Add two caller workflows. They pass every variable and secret without naming any, so they are identical in every project. Adding a value needs no change to them.
# .github/workflows/preview-build.yaml: builds every branch except main
name: Preview build
on:
push:
branches-ignore: [main]
concurrency:
group: preview-build-${{ github.ref_name }}
cancel-in-progress: true
jobs:
build:
uses: ho-nl/deployyy/.github/workflows/magento2-build.yml@main
permissions:
contents: read
packages: write
actions: read
with:
vars: ${{ toJSON(vars) }}
secrets:
all: ${{ toJSON(secrets) }}# .github/workflows/build.yaml: builds main
name: Build
on:
push:
branches: [main]
concurrency:
group: build-${{ github.ref_name }}
cancel-in-progress: false
jobs:
build:
uses: ho-nl/deployyy/.github/workflows/magento2-build.yml@main
permissions:
contents: read
packages: write
actions: read
with:
vars: ${{ toJSON(vars) }}
secrets:
all: ${{ toJSON(secrets) }}What each part does:
secrets: all: ${{ toJSON(secrets) }}passes every secret under one declared name. GitHub allows that from any organization.secrets: inheritworks only inside theho-nlorganization, so other repositories would build without their secrets.vars: ${{ toJSON(vars) }}passes every variable.packages: writepushes the images.actions: readreads the GitHub Environment named after the branch.
The build jobs use only the permissions the caller grants. A missing permission produces a warning in the build log:
| Missing | Effect |
|---|---|
actions: read |
The branch's GitHub Environment is not used. |
A caller written before 2026-10-07 uses secrets: inherit or passes
COMPOSER_AUTH by name, without permissions. Such a caller builds, with a
warning to switch to the workflows above.
During the build:
- Every secret is masked in the log.
- Every value is an environment variable of the build steps.
- Every value is in the BuildKit secret
build-env. A repositoryDockerfilereads it withRUN --mount=type=secret,id=build-env.
These values reach the build only. The running application reads its
variables from Deployyy: Environment variables in the console, or
deployyy env add.
Limits:
- The build job runs in the branch's GitHub Environment. That Environment's protection rules, such as required reviewers, wait timers and branch rules, apply to the build.
- The calling job cannot run in a GitHub Environment. Its
toJSON(secrets)holds only repository and organization secrets. Environment secrets come in through the build job, which needsactions: read. - Organization secrets and variables arrive only if the organization grants them to the repository.
- GitHub runs some builds without secrets, such as a pull request from a fork or a Dependabot push. Those builds get no secret values.
- Names must be valid shell variable names: letters, digits and underscores, not starting with a digit. Other names are skipped with a warning.
- GitHub stores secret names in upper case.
- A name used for both a variable and a secret is treated as a secret.
- Names starting with
DEPLOYYY_are build settings. - Values from the project's services, such as database and cache settings, override yours.
Push a branch. The workflow builds two images and pushes them to
ghcr.io/<owner>/<repo> with commit tags php-fpm-<sha7> and nginx-<sha7>.
When the tags exist, the preview environment deploys at
https://<branch>.<project>.deployyy.app. The workflow does not deploy.
Your repository holds no Deployyy configuration file. Project settings live in the Deployyy console.
- Release line: read from
composer.json. - Service stack: database, search, queue and cache are project settings.
- PHP version: the release line's default unless you choose another
supported version in the console. Defaults: 2.4.6 uses 8.2, 2.4.7 uses 8.3,
2.4.8, 2.4.9 and Mage-OS 3 use 8.4.
- The choice is stored in the repository variable
DEPLOYYY_PHP. - Images then carry the version in their tag, such as
php-fpm-8.3-<sha7>. - After a change, push or re-run the latest build. The environment keeps its current release until the new build exists.
- PHP 8.5 builds on 2.4.9 and Mage-OS 3. The console offers 8.5 after its live trial.
- A repository
Dockerfilereceives the version as build argumentPHP_VERSION.
- The choice is stored in the repository variable
- Static-content locales: the repository variable
DEPLOYYY_MAGENTO_LOCALES, set from the project settings. Default:en_US.
Do not edit DEPLOYYY_* repository variables by hand. Project settings
overwrite them.
To change a setting the console does not offer, contact Deployyy support.
The application can live in a folder, such as src or apps/web. The
project's root directory names that folder.
- If the repository root holds no application, connecting the repository sets the root directory to the subfolder that holds one.
- The repository variable
DEPLOYYY_ROOT_DIRholds the folder. It is removed when the application is at the root. - The build reads
composer.json,composer.lock,package.json,.nvmrc,.node-version,Dockerfile,.platform/,deployyy.json,patches/,.trivyignoreand the Varnish VCL files from that folder. - The folder is the Docker build context. Files outside it do not reach the image.
- In this README, "the root of your repository" means the application folder.
- The caller workflows stay the same.
The folder must be a relative path inside the repository. It cannot start
with /, contain . or .., or be a symbolic link. It must exist in the
commit being built. Otherwise the build fails with the broken rule. An empty
value means the repository root.
One repository can hold several applications, each in its own folder. An
example: Magento in magento and its GraphCommerce storefront in
storefront. Each application is a separate Deployyy project with its own
folder, and builds and deploys on its own.
- Callers: one caller workflow per type, such as
magento2-build.ymlandgraphcommerce-build.yml. Neither names a folder. - Settings: the repository variable
DEPLOYYY_APPSlists each application's folder, image and settings. It replacesDEPLOYYY_ROOT_DIR,DEPLOYYY_PHP,DEPLOYYY_MAGENTO_LOCALESandDEPLOYYY_GC_MAGENTO_ENDPOINTS, which are then removed. Do not editDEPLOYYY_APPSby hand. - Jobs: each workflow builds the applications of its type, one job per
application, named
build (<folder>). - Images: the repository's first project publishes to
ghcr.io/<owner>/<repo>. Every other project publishes toghcr.io/<owner>/<repo>/<folder>, so tags never collide. - A push builds only changed applications. If a push changes nothing in a
folder, the new commit's tags point at the previous push's release. That job
is named
re-use (<folder>). The folder is the whole build context, so the image is identical. - Full builds: every application builds on a manual run
(
workflow_dispatch), a re-run, a force push or a new branch. An application also builds when its previous release is missing. An application at the repository root always builds. - Settings outside the folder, such as a variable, the locales or a recipe update, apply with the application's next change. To apply them immediately, run the workflow by hand.
- Files at the repository root, such as a lockfile or
.github, are outside every folder and trigger no build. An application that needs them must use the repository root as its folder. - A storefront pairs with the Magento application of the same repository, per branch.
The page cache uses the Deployyy VCL. To change it, commit one of these files to the root of your repository:
varnish-snippet.vclis added to the Deployyy VCL. Asub vcl_recv { … }in it runs first, then continues into the Deployyyvcl_recv.varnish.vclreplaces the Deployyy VCL. Use backend hostmagento-web, port8080. The purge ACL must allow10.0.0.0/8,172.16.0.0/12and192.168.0.0/16.
The recipes copy the file into the image. An environment compiles the VCL from the release it runs, with the same Varnish version. A VCL that fails to compile is not used. The previous VCL keeps serving, and the console shows the compile error.
The recipes add no Varnish or GraphQL module to your project. A caching
module, if you need one, goes in your composer.json.
-
Release line. The workflow reads the release line from
composer.json. For example,mage-os/product-community-edition: 3.*maps tomageos-3. The match is on the Mage-OS major version. -
Recipe. The workflow builds with the recipe for that line from
recipes/: a Dockerfile plus.platform/support files. Recipe updates apply to your project on its next build. -
Your own files. A
Dockerfilein your repository replaces the recipe Dockerfile. A file in your.platform/directory replaces only the recipe file with the same name. -
Patches. Put vaimo/composer-patches files in a
patches/directory. Declare each one inextra.patchesofcomposer.json:"extra": { "patches": { "mage-os/magento2-base": { "What the patch fixes": "patches/the-fix.patch" } } }
The recipe copies
patches/into the build beforecomposer install, so the plugin applies them. After the install,.platform/check-patches.phpfails the build for every*.patchfile inpatches/that is not in the installed code.Use
extra.patches, not theextra.patches-searchfolder scan. The scan skips a patch whose header names no installed package. It also skips every patch for a branch install such asdev-main, unless the header has@version *.check-patches.phpthen fails the build for those patches.Add
@skipto the header of a patch file that must not be applied. A project withoutpatches/is not affected.
When your project installs
graphcommerce/magento-fast-boot,
the recipes switch it on:
- The web container runs
bin/magento fastboot:preparebefore PHP-FPM starts. If it fails, FastBoot fills its caches on the first requests. - PHP-FPM preloads the classes listed in
preload-classes.txtat the root of your repository. Without that file, nothing is preloaded. A dev box never preloads, because its code changes.
To make the list, copy var/cache/preload/classes.txt from a running web
container after it has served real traffic, and commit it as
preload-classes.txt. Refresh it when the classes your shop uses change.
| Line | Directory | PHP | Status |
|---|---|---|---|
mageos-3 |
recipes/mageos-3/ |
8.4, 8.5 not validated | Validated live on 3.2.0 and 3.4.0 with PHP 8.4 |
mageos-4, Magento core 2.4.9 |
recipes/mageos-3/ |
8.4, 8.5 not validated | Not validated live. |
magento-249, Magento Open Source 2.4.9 |
recipes/magento/ |
8.4, 8.5 not validated | Image builds and starts. Not validated live. |
magento-248, 2.4.8 |
recipes/magento/ |
8.4 | Image builds and starts. Not validated live. |
magento-247, 2.4.7 |
recipes/magento/ |
8.3 | Image builds and starts. Not validated live. |
magento-246, 2.4.6 |
recipes/magento/ |
8.2 | Image builds and starts. Not validated live. |
| Next.js, GraphCommerce | recipes/nextjs/ |
Node 20, 22, 24 | Validated locally on Next 14, 15 and 16. Not validated live. |
| Laravel | recipes/laravel/ |
8.2 to 8.4 | See Laravel. |
The Magento Open Source lines share one recipe. The recipe handles these known problems:
- Cache types come from the installed modules, every
etc/cache.xml, not from the Magento version. Magento disables a cache type that is missing fromenv.php, without a warning. queue_poison_pillhas no primary key in core. MySQL Group Replication refuses writes to such a table, andsetup:upgradewrites to it. MariaDB and single-node MySQL accept the table. Build argumentPLATFORM_QUEUE_POISON_PILL_PK=1adds the moduleDeployyy_QueuePoisonPillPk, unless a module in your project declares the key. The nextsetup:upgradethen adds the key. The build workflows do not set this argument.- Patches:
patches/is in the build beforecomposer install. A patch file that is not in the installed code fails the build, see step 4 above. - Minification: the build records whether it deployed minified JS and CSS
in
app/etc/static_build.php.env.phpsets runtime minification to match, so database settings cannot request files missing from the image.
Services. env.php reads each service by its generic name. A service's
variables start with its name. The default services are db, search,
session, cache, queue and media. The recipe reads:
DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORDSEARCH_HOST,SEARCH_PORTSESSION_HOST,SESSION_PORTCACHE_HOST,CACHE_PORTQUEUE_HOST,QUEUE_PORT,QUEUE_USER,QUEUE_PASSWORD,QUEUE_VHOSTMEDIA_BUCKET,MEDIA_ENDPOINT,MEDIA_REGION,MEDIA_ACCESS_KEY_ID,MEDIA_SECRET_ACCESS_KEY
The queue and the media bucket are optional. Without QUEUE_HOST, Magento
uses its MySQL queue. Without MEDIA_BUCKET, Magento keeps files on local
storage.
env.php is the platform's. A repository cannot bring its own. The build
installs the recipe's env.php at .platform/env.php, replacing a committed
one with a warning to delete it. A repository with its own Dockerfile must copy
.platform/env.php to /var/www/html/app/etc/env.php; the build fails when the
image's env.php differs. Set shop configuration
that differs per environment as a CONFIG__… variable instead, for example
CONFIG__DEFAULT__SMTP__CONFIGURATION_OPTION__HOST: Magento reads these like an
env.php setting and locks the field in the admin. A changed variable needs no
app:config:import. Configuration that is the same everywhere belongs in
app/etc/config.php.
The platform sets these paths as CONFIG__… variables, and its value replaces
a project variable of the same path: the base URLs of every scope,
web/url/redirect_to_base, the admin URL, the search host and port, the page
cache (system/full_page_cache/caching_application) and, below production, the
cookie domain and the static and media base URLs. A store or website code with
a capital letter or __ cannot be named by a variable; its base URLs stay in
env.php's system section.
Configuration check. The recipes disable a Magento safety check: the
plugin configHash (Magento\Deploy\Model\Plugin\ConfigChangeDetector, on
Magento\Framework\App\FrontControllerInterface in Magento_Deploy's
etc/di.xml). With it, every page, GraphQL and REST request answers 500 "The
configuration file has changed" while the configuration in app/etc differs
from the last app:config:import. The build adds the module
Deployyy_ConfigCheckOff (.platform/config-check-off.php), fails when the
plugin still runs in a compiled area, and labels the image
app.deployyy.config-check=off. On that label the platform imports a
release that changes only configuration while the previous release keeps
serving, with no maintenance page. This is safe on the platform because:
- each process reads its own
config.phpandenv.phpsystemvalues and itsCONFIG__variables, which override the database at runtime; - the cache key prefix is a digest of that configuration, so two releases never share cached configuration;
- the platform runs
app:config:importfor every release that changes the configuration, while the previous release serves, and reports a failed import on the deployment.
The remaining gap: a store, website or theme that is new in config.php is
missing from the database until the import has run, and one removed from
config.php is gone from the database for the previous release from then on. Cron, consumers and bin/magento commands never ran this
check. app:config:status and config:set still compare the hashes.
A release line without a recipe fails the build, with the list of supported
lines. Adobe Commerce has no recipe. For Adobe Commerce, add your own Dockerfile.
Every build pushes its images by digest first, then scans each one with Trivy:
- Critical with a fix: a critical vulnerability that has a fixed version.
With the repository variable
DEPLOYYY_SCAN_ENFORCE=1, such a finding fails the build. Without that variable, the build logs a warning and continues. - Other findings: listed in the job summary with counts and the critical
and high rows. The full report is the run artifact
trivy-<image>. - Accepted findings: add the ID to
.trivyignoreat the repository root, with a comment giving the reason.
Enforcement is off by default. When the scan was introduced, most running
images had a critical finding with a fix. Examples: npm's bundled tar in
the PHP base image, OpenSSL in nginx alpine, older next versions. Fix your
findings, then set DEPLOYYY_SCAN_ENFORCE=1.
The build tags the images only after every image passes the scan. Without enforcement, a finding does not block the tags. Environments deploy only tagged images.
Each image carries an SBOM and a provenance attestation. To read them:
docker buildx imagetools inspect ghcr.io/<owner>/<repo>:<tag> --format '{{json .SBOM}}'
docker buildx imagetools inspect ghcr.io/<owner>/<repo>:<tag> --format '{{json .Provenance}}'Images from recipes/magento and recipes/nextjs run as a non-root user,
without sudo and without extra Linux capabilities. Details:
docs/PSS-RESTRICTED.md.
A Next.js or GraphCommerce project builds with nextjs-build.yml or
graphcommerce-build.yml. Both run the same build.
# .github/workflows/build.yaml: builds every branch
name: Build
on: push
concurrency:
group: build-${{ github.ref_name }}
cancel-in-progress: true
jobs:
build:
uses: ho-nl/deployyy/.github/workflows/graphcommerce-build.yml@main
permissions:
contents: read
packages: write
actions: read
with:
vars: ${{ toJSON(vars) }}
secrets:
all: ${{ toJSON(secrets) }}Variables and secrets work as for Magento, see Step 2 and Step 3.
The workflow pushes two images to ghcr.io/<owner>/<repo>:
sha-<sha7>: the Next.js standalone server,node server.json port 3000.sha-<sha7>-cache-seed: the pagesnext buildprerendered. They are loaded into the shared page cache before the release serves traffic, so first visitors get cached pages.
Requirements:
- A
buildscript inpackage.json, ornextas a dependency. - A route that returns HTTP 200 on
GET /api/health, or another health path set in the project settings. A release serves traffic only after its health path returns 200. The build logs a warning when/api/healthis missing. - Node 20, 22 or 24, from
.nvmrc,.node-versionorengines.node. Default: 22. The package manager follows your lockfile: npm, Yarn or pnpm.
Every variable and secret is available to next build and, at runtime, to
the server in process.env. As on Vercel, only NEXT_PUBLIC_* values end up
in the browser bundle.
A GraphCommerce storefront paired with a Magento project builds against one Magento environment. The build picks the first that exists:
- The Magento environment of the same branch.
- The environment of the branch your pull request merges into, and so on up to the main branch.
- The Magento project's main branch.
The repository variable DEPLOYYY_GC_MAGENTO_ENDPOINTS holds each branch's
endpoint as JSON. The build sets the branch's entry as GC_MAGENTO_ENDPOINT.
That value overrides magentoEndpoint in graphcommerce.config.js and any
GC_MAGENTO_ENDPOINT variable of your own.
Before building, the workflow waits up to 10 minutes for the endpoint to
return a GraphQL response. The request also wakes a paused preview. Do not
edit DEPLOYYY_GC_MAGENTO_ENDPOINTS by hand. Without the variable, your own
endpoint is used.
Your project needs no cache code or cache configuration. The build wraps
your next.config (.js, .mjs, .ts or .mts) and adds:
cacheHandler:recipes/nextjs/platform/cache-handler.mjs. ISR pages and fetch results go to the shared cache storage atCACHE_DIR. One revalidation updates every instance. WithoutCACHE_DIR, for example in local development, each process has its own cache.cacheMaxMemorySize: 0, so no instance keeps a stale copy in memory.output: 'standalone', if you set nooutput.- The commit as the build ID, if you set no
generateBuildId.
Images from /_next/image are not in this cache. The content delivery
network caches them.
If your next.config sets its own cacheHandler, the build keeps it and logs
a warning. output: 'export' fails the build, because environments run a
Next.js server.
The shared cache is on or off per project in the console. Switching it needs no
new build: the operator adds or removes CACHE_DIR and the /cache storage and
restarts the environment, and the handler follows CACHE_DIR.
A repository with its own Dockerfile gets none of the above, so the shared
cache stores nothing until the build carries a handler. Add once, then build:
cacheHandlerpointing at a handler that writes toprocess.env.CACHE_DIRwhen it is set. Copyrecipes/nextjs/platform/cache-handler.mjsnext to yournext.configand make sure it is in the standalone output.cacheMaxMemorySize: 0, so no instance serves a stale copy from memory.
A Dockerfile in your repository replaces the recipe. A file in .platform/
replaces the recipe file with the same name.
A Laravel project builds with laravel-build.yml:
# .github/workflows/build.yaml: builds every branch
name: Build
on: push
concurrency:
group: build-${{ github.ref_name }}
cancel-in-progress: true
jobs:
build:
uses: ho-nl/deployyy/.github/workflows/laravel-build.yml@main
permissions:
contents: read
packages: write
actions: read
with:
vars: ${{ toJSON(vars) }}
secrets:
all: ${{ toJSON(secrets) }}- PHP: from
require.phpincomposer.json, 8.2 to 8.4. - Images:
recipes/laravelbuildsphp-fpm-<sha7>andnginx-<sha7>, with your Vite or Mix assets if there is apackage.json. - Own files: a
Dockerfilein your repository replaces the recipe. A file in.platform/replaces the recipe file with the same name. - Configuration: environment variables set
DB_*,REDIS_*,APP_URL,APP_KEY, mail and S3. Do not commit a.envfile. - Your values: variables and secrets from GitHub, per repository or per GitHub Environment, override the configuration values above.
- Migrations:
php artisan migrate --forceruns before a release serves traffic, unless the project declares its own release commands. If a migration fails, the previous release keeps serving.