Deploy Magento 2.4.8 on Kubernetes with git-sync support for live plugin development.
- Magento 2.4.8 Community Edition with sample data (2000+ products)
- Single-pod deployment with MariaDB and OpenSearch sidecars
- Git-sync sidecars for live code updates during development
- Configurable via Helm values
# Using OCI registry
helm install magento oci://ghcr.io/brtkwr/charts/magento \
--set magento.host=magento.example.com \
--set adminUsers[0].password=YourSecurePassword123 \
--set database.password=YourDBPassword123
# Or clone locally
git clone https://github.com/brtkwr/magento-helm.git
cd magento-helm
helm install magento ./charts/magento \
--set magento.host=magento.example.com \
--set adminUsers[0].password=YourSecurePassword123 \
--set database.password=YourDBPassword123- Kubernetes 1.24+
- Helm 3.x
- PersistentVolume provisioner
- Ingress controller (nginx, traefik, etc.)
See values.yaml for all options.
| Parameter | Description | Default |
|---|---|---|
magento.host |
Store URL hostname | magento.example.com |
magento.currency |
Store currency | GBP |
magento.language |
Store language | en_GB |
existingSecret |
Name of existing secret for passwords | "" |
extraEnv |
Extra environment variables for containers | [] |
adminUsers |
Array of admin users | (see below) |
database.password |
MariaDB password (ignored if existingSecret set) | (generated) |
gitSync.plugins |
Array of git repos to sync | [] |
The first user in the array is the primary admin (used for setup:install). All users are synced on every pod restart.
adminUsers:
- username: admin
email: admin@example.com
firstname: Admin
lastname: User
password: securepassword123
- username: developer
email: dev@example.com
firstname: Dev
lastname: User
password: anotherpassword456Note: Roles must be assigned via the Magento admin panel - the CLI doesn't support role assignment.
Instead of passing passwords via --set or values files, you can reference a pre-existing Kubernetes secret:
existingSecret: my-magento-secretsWhen existingSecret is set, the chart will not create its own secret and will reference the existing one instead.
Required secret keys:
| Key | Description |
|---|---|
database-password |
MariaDB root and user password |
<passwordKey> |
Password for each admin user (key name specified in values) |
Creating the secret:
kubectl create secret generic my-magento-secrets \
--namespace magento \
--from-literal=database-password=MyDBPassword123 \
--from-literal=admin-password-admin=AdminPass123 \
--from-literal=admin-password-developer=DevPass456Example values file with existingSecret:
existingSecret: my-magento-secrets
adminUsers:
- username: admin
email: admin@example.com
firstname: Admin
lastname: User
passwordKey: admin-password-admin # References key in secret
- username: developer
email: dev@example.com
firstname: Dev
lastname: User
passwordKey: admin-password-developer
database:
name: magento
user: magento
# password not needed - read from secretYou can inject additional environment variables into the Magento containers using extraEnv. This is useful for API keys or other secrets needed in lifecycle hooks.
extraEnv:
- name: TWO_API_KEY
valueFrom:
secretKeyRef:
name: my-magento-secrets
key: api-key
- name: SOME_OTHER_VAR
value: "static-value"These variables are available in both the init-setup container and the main magento container, so you can reference them in your hooks.postSetup scripts:
hooks:
postSetup: |
php -r "
\$key = getenv('TWO_API_KEY');
// use the key...
"# values-production.yaml
magento:
host: shop.example.com
currency: USD
timezone: America/New_York
adminUsers:
- username: admin
email: admin@example.com
firstname: Admin
lastname: User
password: "" # Set via --set
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
enabled: true
resources:
magento:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "4000m"helm install magento oci://ghcr.io/brtkwr/charts/magento \
-f values-production.yaml \
--set adminUsers[0].password=$ADMIN_PASSWORD \
--set database.password=$DB_PASSWORD┌─────────────────────────────────────────────────────────┐
│ Pod: magento │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Magento │ │ MariaDB │ │ OpenSearch │ │
│ │ PHP+Apache │ │ 10.6 │ │ 2.19 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ git-sync (optional) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ PersistentVolumeClaim │
│ /data/mysql │ /data/opensearch │ /data/magento │
└─────────────────────────────────────────────────────────┘
The chart supports git-sync sidecars for live plugin development. Changes pushed to your repo are reflected within 60 seconds.
- Init container clones the repo with
--one-timeto ensure code exists before setup - Sidecar container continuously syncs with
--period=60s --link=codecreates a stable symlink that git-sync maintains (handles worktree hash changes automatically)- Setup scripts create symlinks from
app/code/Vendor/Module→git-sync/plugin/code
gitSync:
plugins:
- name: my-plugin
repo: https://github.com/your-org/your-magento-plugin.git
branch: main
path: app/code/YourVendor/ModulegitSync:
plugins:
- name: custom-shipping
repo: https://github.com/your-org/magento-shipping.git
branch: main
path: app/code/YourVendor/Shipping
- name: custom-payment
repo: https://github.com/your-org/magento-payment.git
branch: develop
path: app/code/YourVendor/PaymentFor private repositories, provide credentials:
gitSync:
credentials:
existingSecret: git-credentials # Secret with 'token' key containing PAT
plugins:
- name: my-plugin
repo: https://github.com/your-org/private-plugin.git
branch: main
path: app/code/YourVendor/ModuleFor production, disable git-sync and bake plugins into your image:
gitSync:
plugins: []Currently, you must manually flush Magento's cache after code changes:
kubectl exec deploy/magento -c magento -- bin/magento cache:flushFuture enhancement: The chart may add --exechook support to automatically flush cache on every sync.
| Setting | Description | Default |
|---|---|---|
staticContentDeploy.enabled |
Run setup:static-content:deploy during setup and on git-sync rotations |
false |
staticContentDeploy.areas |
Areas to deploy | [frontend, adminhtml] |
staticContentDeploy.locales |
Locales to deploy | [en_US] |
staticContentDeploy.signing |
Set dev/static/sign=1 so asset URLs carry /version<ts>/ |
false |
staticContentDeploy.atomicSwap |
Build into an idle A/B slot and go live with an atomic symlink flip | true |
setup:static-content:deploy empties pub/static and only rewrites it a
couple of minutes later, and it bumps deployed_version.txt at the start
of that window. Under magento.mode=production every /static/ URL 404s for
the duration and the storefront 500s with Unable to retrieve deployment version of static files from the file system. When gitSync.reload is on,
each plugin push triggers a rebuild, so a busy branch keeps the shop down for
much of the day.
With atomicSwap (the default) pub/static is a symlink to one of two
real directories, pub/static-a and pub/static-b:
pub/static -> pub/static-b # live, serving
pub/static-a # idle, next build target
A deploy is pointed at the idle slot (bin/magento ... --bootstrap=MAGE_DIRS[static][path]=/var/www/html/pub/static-<slot>), and it
goes live with a single rename(2) of the symlink once
deployed_version.txt is present in the new slot. Consequences:
- No request ever sees a missing or half-populated
pub/static. - A failed build never touches the live slot — nothing is flipped, and the git-sync watcher retries on its next poll.
- Rollback on a failed post-rotation smoke check is a flip back to the
previous slot instead of an rsync restore, so it is instant and cannot
itself fail half-way. The
var/.pub-static-prevsnapshot (~500M of PVC writes per rotation) is no longer taken. - Peak static disk roughly doubles (~2×
pub/static, typically ~1G). Both slots live on the container filesystem, not the PVC, which only mountsvar,pub/mediaandapp/etc. No container in this chart setsephemeral-storagerequests or limits, so this space is unaccounted for by the scheduler — size nodes accordingly. - Two slots only; the previously live one is reused as the next build target, so nothing accumulates.
- Existing pods convert on their next start:
pub/staticis hardlinked into slota(no data copied) and replaced by the symlink, in the init container, before Apache accepts traffic. Re-runs and crash-interrupted conversions are handled. - Content Magento generates into
pub/staticat runtime (merged/minified JS+CSS under_cache/merged) always lands in the live slot, and is regenerated lazily after a flip — the same as after any-fdeploy today.
The chart probes the bootstrap override with
--refresh-content-version-only before trusting a real build to it. If the
image ignores the override, or the slot layout cannot be created, the chart
logs and falls back to the historical in-place deploy, so the worst case is
the previous behaviour rather than a broken one. Set atomicSwap: false to
force that path.
Public images are available at:
| Image | Description |
|---|---|
ghcr.io/brtkwr/magento-base:php8.2 |
PHP 8.2 + Apache + extensions |
ghcr.io/brtkwr/magento:2.4.8 |
Magento + sample data |
To add custom modules or themes:
cd docker/
# Create auth.json with repo.magento.com credentials
cat > auth.json << 'EOF'
{
"http-basic": {
"repo.magento.com": {
"username": "YOUR_PUBLIC_KEY",
"password": "YOUR_PRIVATE_KEY"
}
}
}
EOF
# Build base image
docker buildx build --platform linux/amd64,linux/arm64 \
-f Dockerfile.base \
-t your-registry/magento-base:php8.2 --push .
# Build Magento image
docker buildx build --platform linux/amd64,linux/arm64 \
--secret id=composer_auth,src=auth.json \
-t your-registry/magento:2.4.8 --push .Then update your Helm values:
image:
repository: your-registry/magento
tag: "2.4.8"ghcr.io/brtkwr/magento-dev is the deployed image plus the two things a
workstation needs: magerun2, and an entrypoint that installs the shop against
compose sidecars on first boot. Both architectures are published, so it runs
natively on Apple Silicon instead of under emulation.
It installs with the same setup:install flags the chart uses (see
charts/magento/templates/configmap.yaml) — keep the two in step, or local dev
stops resembling what gets deployed.
services:
magento:
image: ghcr.io/brtkwr/magento-dev:2.4.8
container_name: magento
ports: ["1234:80"]
environment:
URL: http://localhost:1234/
MYSQL_HOST: db
MYSQL_PASSWORD: magento
OPENSEARCH_HOST: search
volumes:
# The module under development, picked up via its registration.php.
- ./:/var/www/html/app/code/Two/Gateway
depends_on: [db, search]
db:
image: mariadb:10.6
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: magento
MARIADB_USER: magento
MARIADB_PASSWORD: magento
search:
image: opensearchproject/opensearch:2.19.0
environment:
discovery.type: single-node
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: -Xms512m -Xmx512mNotes:
- Wait on
var/.dev-install-complete, notbin/magento --version. The CLI answers long before the shop is usable; the entrypoint touches that file only once the install has finished. - Mount the module at
app/code/, don'tcomposer requireit. A require re-resolves the whole dependency tree againstrepo.magento.comand so needs Magento credentials at runtime. The bind mount needs none. - The URL scheme matters.
setup:installrejects a non-https--base-url-secure, so a plainhttp://dev URL is installed with the secure flags off. - No Magento credentials needed to run it. The six
community-engineering/language-*packs are installed into the image at build time, where CI holds the keys, so nothing at runtime has to reachrepo.magento.com. - Defaults: admin
exampleuser/examplepassword123, databasemagentoon hostdb, OpenSearch onsearch:9200. Override via the environment variables read at the top ofdocker/dev-entrypoint.sh.
After deployment:
| URL | Description |
|---|---|
https://your-host/ |
Storefront |
https://your-host/admin |
Admin Panel |
https://your-host/health_check.php |
Health Check |
Default admin credentials are set via adminUsers[0].username and adminUsers[0].password.
The chart persists data across pod restarts using a PVC with the following mounts:
| Mount Path | PVC Subpath | Purpose |
|---|---|---|
/var/www/html/var |
magento/var |
Logs, cache, sessions |
/var/www/html/pub/media |
magento/media |
Uploaded media files |
/var/www/html/app/etc |
magento/etc |
env.php, config.php |
-
init-etccontainer - On first boot, copies defaultapp/etcfrom the Docker image to the PVC. On subsequent boots, preserves existing files (includingenv.php). -
Setup script - Detects if Magento is already installed by checking for
app/etc/env.php:- If exists: Runs
setup:upgrade+di:compile(fast upgrade path) - If missing: Runs full
setup:install+ copies sample data media + runscatalog:images:resize
- If exists: Runs
-
Lifecycle hooks - Custom commands run at various stages (configure stores, rebuild CSS, etc.)
This ensures pod restarts are idempotent - they complete without manual intervention.
The chart provides hooks to inject custom scripts at different stages:
| Hook | When it runs |
|---|---|
hooks.preSetup |
Before any setup (both upgrade and install paths) |
hooks.postUpgrade |
After setup:upgrade on existing installs only |
hooks.postInstall |
After setup:install on fresh installs only |
hooks.postSetup |
After all setup completes (both paths) |
Lifecycle flow:
Fresh install: Existing install:
───────────── ─────────────────
preSetup preSetup
↓ ↓
setup:install setup:upgrade
↓ ↓
postInstall postUpgrade
↓ ↓
postSetup postSetup
Example:
hooks:
postSetup: |
# Create store views
bin/magento store:create 'Hyva Checkout' hyva || true
# Configure payment gateway
bin/magento config:set payment/my_method/active 1 || true
# Rebuild CSS
npm --prefix vendor/hyva-themes/magento2-default-theme/web/tailwind/ run build-prod
bin/magento cache:flushUse || true for commands that may fail during initial setup (e.g., config paths that don't exist yet).
kubectl exec deploy/magento -c magento -- tail -f /var/www/html/var/log/setup.logkubectl exec deploy/magento -c magento -- \
chown -R www-data:www-data /var/www/html/var /var/www/html/generatedkubectl exec deploy/magento -c magento -- bin/magento cache:flushkubectl exec deploy/magento -c magento -- bin/magento indexer:reindexkubectl exec deploy/magento -c magento -- bin/magento module:statusSymptom: Pod stuck in Init:CrashLoopBackOff. OpenSearch init container logs show java.io.IOException: No space left on device. The magento init-setup container hangs on "Waiting for OpenSearch..." until it times out.
Root cause: Magento's file-based full-page cache (var/page_cache) grows unbounded. Crawler bots — especially meta-webindexer from Facebook — crawl all layered navigation filter combinations, creating a unique cache entry per combination. With multiple store views and combinatorial filter params, the cache can grow to 8 GB+ with 70k+ files and fill even a generously-sized PVC.
Diagnosis: the pod is pending so you can't kubectl exec into it. Launch a one-shot debug pod mounted on the same PVC instead:
kubectl run -n <namespace> pvc-debug --restart=Never --image=busybox \
--overrides='{"spec":{"containers":[{"name":"pvc-debug","image":"busybox","command":["sh","-c","du -sh /data/* /data/magento/* /data/magento/var/* 2>/dev/null | sort -hr"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"<release-name>"}}]}}' \
&& sleep 5 \
&& kubectl logs -n <namespace> pvc-debug \
&& kubectl delete pod -n <namespace> pvc-debug --wait=falseFix (clear page cache):
kubectl run -n <namespace> pvc-clear --restart=Never --image=busybox \
--overrides='{"spec":{"containers":[{"name":"pvc-clear","image":"busybox","command":["sh","-c","rm -rf /data/magento/var/page_cache/* && echo CLEARED"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"<release-name>"}}]}}' \
&& sleep 5 \
&& kubectl logs -n <namespace> pvc-clear \
&& kubectl delete pod -n <namespace> pvc-clear --wait=false \
&& kubectl rollout restart deployment/<release-name> -n <namespace>Prevention: block crawlers at your ingress. robots.txt is advisory only — meta-webindexer ignores it. Example nginx-ingress annotation:
ingress:
annotations:
nginx.ingress.kubernetes.io/server-snippet: |
if ($http_user_agent ~* (bot|crawler|spider|meta-webindexer|googlebot|bingbot|facebookexternalhit)) {
return 403;
}Longer-term: switch Magento's cache backend to Redis (cache.frontend.default.backend) so page cache no longer lives on the PVC, and/or schedule a bin/magento cache:clean full_page cron.
Symptom: Luma homepage renders as a flat stack of text/images instead of the styled hero banner + promo grid. Broken <img> placeholders for /media/wysiwyg/home/*.jpg and/or console errors like:
Refused to apply style from '.../media/styles.css' because its MIME type
('text/html') is not a supported stylesheet MIME type
Root cause: Magento's sample data lives in two separate vendor packages and populates the PVC in different places:
| Package | Populates | Chart-level restore on upgrade |
|---|---|---|
vendor/magento/sample-data-media |
pub/media/catalog/product/**, pub/media/wysiwyg/** |
Yes (chart ≥ 0.11.1, copied via cp -rn in init-setup) |
vendor/magento/module-cms-sample-data/fixtures/styles.css |
pub/media/styles.css — the actual stylesheet that styles .block-promo.home-main, .block-promo-wrapper, etc. |
No — fresh install only, via fixture loader |
The design/head/includes core_config_data row injects <link rel="stylesheet" href="{{MEDIA_URL}}styles.css"> into every page. When the PVC loses pub/media/styles.css (e.g. reprovisioned or restored from a backup that didn't include media), the link resolves to a 404 HTML page, browsers reject it for wrong MIME type, and the Luma CMS grid renders unstyled.
Diagnosis:
# Does the page reference the stylesheet?
curl -s https://your-host/ | grep '/media/styles.css'
# Does the stylesheet actually serve as CSS?
curl -sI https://your-host/media/styles.css
# Expect: 200 + content-type: text/css. If 404 or text/html → broken.
# Is the file on disk?
kubectl exec deploy/magento -c magento -- ls -la /var/www/html/pub/media/styles.css
# Is the head-includes config present?
kubectl exec deploy/magento -c magento -- bash -c \
"mysql -h 127.0.0.1 -u magento -p\"\$MYSQL_PASSWORD\" magento --skip-ssl -e \
'SELECT path, value FROM core_config_data WHERE path=\"design/head/includes\"'"Fix:
POD=$(kubectl get pod -l app.kubernetes.io/name=magento -o name | head -1)
# 1. Restore the stylesheet from the vendor fixture
kubectl exec -c magento $POD -- bash -c \
"cp /var/www/html/vendor/magento/module-cms-sample-data/fixtures/styles.css \
/var/www/html/pub/media/styles.css && \
chown www-data:www-data /var/www/html/pub/media/styles.css"
# 2. Restore the head-include config if missing
kubectl exec -c magento $POD -- bash -c \
"mysql -h 127.0.0.1 -u magento -p\"\$MYSQL_PASSWORD\" magento --skip-ssl -e \
'INSERT INTO core_config_data (scope, scope_id, path, value)
VALUES (\"default\", 0, \"design/head/includes\",
\"<link rel=\\\"stylesheet\\\" type=\\\"text/css\\\" media=\\\"all\\\" href=\\\"{{MEDIA_URL}}styles.css\\\" />\")
ON DUPLICATE KEY UPDATE value=VALUES(value)'"
# 3. Flush config + page cache
kubectl exec -c magento $POD -- \
runuser -u www-data -- bin/magento cache:clean config full_pageHard reload the page (Cmd/Ctrl+Shift+R) to bypass the browser's cached broken-MIME response.
For the same PVC also missing wysiwyg/ or catalog/product/ contents (product thumbnails broken, not just the CMS grid): chart ≥ 0.11.1 handles this automatically via idempotent cp -rn of sample-data-media on init-setup. On older chart versions, restore manually:
kubectl exec -c magento $POD -- bash -c \
"cp -rn /var/www/html/vendor/magento/sample-data-media/* /var/www/html/pub/media/ && \
chown -R www-data:www-data /var/www/html/pub/media && \
runuser -u www-data -- bin/magento catalog:images:resize"- Fork the repository
- Create a feature branch
- Make your changes
- Submit a pull request
MIT License - see LICENSE for details.