Files
symfony-flex-recipes-contrib/fyrst.shopware-cd.1.0.json
T
github-action[bot]andgithub-action[bot] 53b246bfa7 Create Flex endpoint
2026-09-13 04:56:34 +00:00

3461 lines
208 KiB
JSON

{
"manifests": {
"fyrst/shopware-cd": {
"manifest": {
"copy-from-recipe": {
"root/": ""
},
"env": {
"SHOPWARE_SHOP_ID": "",
"SHOPWARE_DEPLOY_ENV": "live",
"SHOPWARE_DATA_BASE": "/var/lib/shopware/data"
},
"post-install-output": [
" * <fg=blue>fyrst/shopware-cd</>",
" Flex copied CI, <comment>.env.example</comment>, <comment>.dockerignore</comment>,",
" and <comment>deploy/</comment> (CD Compose + VPS helpers) into the shop root.",
"",
" This recipe requires <comment>shopware/docker</comment>, which copies",
" <comment>docker/Dockerfile</comment>. CI and Compose default to that file",
" (<comment>DOCKERFILE=docker/Dockerfile</comment>).",
"",
" It does not copy shop-root <comment>compose.yaml</comment>, <comment>.gitignore</comment>,",
" or <comment>.shopware-project.yml</comment> (create\u2019s default;",
" <comment>.shopware-project.yaml</comment> is also accepted \u2014 do not rename)",
" \u2014 those come from <comment>shopware-cli project create</comment>.",
"",
" CD Compose files live under <comment>deploy/</comment>:",
" <comment>deploy/compose.yaml</comment>, <comment>deploy/compose.prod.yaml</comment>,",
" <comment>deploy/compose.vps.yaml</comment>. From the shop root:",
" <comment>docker compose --project-directory . -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml</comment>",
"",
" VPS runtime copy (no S3): <comment>deploy/sync-runtime.sh</comment> plus",
" <comment>deploy/sync.env.example</comment> (see <comment>deploy/sync-runtime.md</comment>).",
" Bind-mounted host dirs default to",
" <comment>${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/{files,media,thumbnail,theme,sitemap}</comment>",
" (<comment>SHOPWARE_SHOP_ID</comment> and <comment>SHOPWARE_DEPLOY_ENV</comment> in <comment>.env</comment>;",
" optional <comment>SHOPWARE_DATA_BASE</comment>). Compose does not require",
" <comment>COMPOSE_PROJECT_NAME</comment> or <comment>SHOPWARE_DATA_ROOT</comment>.",
" WARNING: <comment>shopware-cli project create</comment> writes",
" <comment>COMPOSE_PROJECT_NAME=sw-shop-\u2026</comment> into shop-root",
" <comment>.env</comment> for local <comment>project dev</comment>. That env var",
" overrides Compose <comment>name:</comment>",
" (<comment>${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}</comment>).",
" On the VPS, comment out that line",
" (<comment>bash deploy/init-env.sh --vps</comment> or by hand).",
" Flex does not delete it on <comment>composer require</comment>",
" (create owns the local flow).",
" Same-host tag-and-load / air-gap: <comment>PULL_POLICY=never</comment>",
" and <comment>SKIP_PULL=1</comment> (or",
" <comment>bash deploy/vps-release.sh --skip-pull</comment>). Default",
" <comment>pull_policy</comment> is <comment>always</comment> for CI/VPS.",
" On staging, copy to",
" <comment>deploy/sync.env</comment> and pull live \u2192 lower.",
"",
" Local <comment>project dev</comment> (laptop): rsync those VPS trees into the",
" project with <comment>deploy/sync-runtime-local.sh --from live --data all</comment>",
" \u2014 not <comment>deploy/sync-runtime.sh</comment>. Laptop <comment>.env</comment> needs",
" <comment>SHOPWARE_SHOP_ID</comment> (same as live).",
"",
" Flex may append a <comment>###> fyrst/shopware-cd ###</comment>",
" SoT block to <comment>.env</comment> (empty shop id; no secrets).",
" It does not overwrite create\u2019s whole <comment>.env</comment>.",
" Then run <comment>bash deploy/init-env.sh --shop-id \u2026</comment>",
" (VPS: add <comment>--vps</comment>; see <comment>deploy/README</comment>).",
" Commit the copied files; <comment>vendor/</comment> is gitignored.",
"",
" GitHub Actions picks up",
" <comment>.github/workflows/cd.yaml</comment>. GitLab still defaults to",
" <comment>.gitlab-ci.yml</comment>: set Settings \u2192 CI/CD \u2192 CI/CD configuration file",
" to <comment>.gitlab-ci.yaml</comment> (or copy/symlink that name).",
"",
" shopware-cli project create writes <comment>.shopware-project.yml</comment>",
" (that extension is fine). shopware-cli accepts both",
" <comment>.yml</comment> and <comment>.yaml</comment>; do not rename.",
" Refresh later with <comment>composer recipes:update fyrst/shopware-cd</comment>.",
" See https://github.com/fyrst-dev/shopware-cd"
]
},
"files": {
"root/.dockerignore": {
"contents": [
"# Overlay .dockerignore \u2014 keep the Shopware project, drop secrets and",
"# paths that shopware-cli project ci rebuilds or that must never enter the image.",
"",
".git",
".gitattributes",
".github",
".gitlab-ci.yml",
".gitlab-ci.yaml",
".gitignore",
"",
"# Local / prod env and Composer auth (passed as BuildKit secrets instead)",
".env",
".env.*",
"!.env.example",
"auth.json",
"**/*.pem",
"**/*.key",
"",
"# Rebuilt during `shopware-cli project ci`",
"/vendor",
"/node_modules",
"",
"# Generated / state (runtime bind mounts on the host, not the image)",
"/var",
"/files",
"/public/bundles",
"/public/theme",
"/public/media",
"/public/thumbnail",
"/public/sitemap",
"/config/jwt",
"",
"# Editor / OS",
".idea",
".vscode",
".DS_Store",
"Thumbs.db",
"*.swp",
"*.bak",
"",
"# Overlay docs and VPS helpers are not needed inside the image",
"# (CD Compose lives under /deploy; shop-root compose.yaml is from project create)",
"*.md",
"/deploy",
"compose.yaml",
"compose.prod.yaml",
"compose.override.yaml",
"compose.override.yml",
"docker-compose*.yml",
"docker-compose*.yaml",
"Makefile",
"",
"# Tests (also list extra cleanup in CLI-owned .shopware-project.yml / .yaml if needed)",
"/tests",
".phpunit.result.cache",
".php-cs-fixer.cache",
".phpstan.cache",
""
],
"executable": false
},
"root/.env.example": {
"contents": [
"# Copy to `.env` (local) and to the VPS `.env` (mode 0600). Never commit filled values.",
"# Compose interpolates ${VAR} from this file. Shopware reads the same names at runtime.",
"#",
"# After `composer require`, Flex may append a ###> fyrst/shopware-cd ### block",
"# (SHOPWARE_SHOP_ID empty, SHOPWARE_DEPLOY_ENV=live, SHOPWARE_DATA_BASE default).",
"# Finish shop-specific values with:",
"# bash deploy/init-env.sh --shop-id <slug>",
"# VPS (comment out create's COMPOSE_PROJECT_NAME=sw-shop-\u2026):",
"# bash deploy/init-env.sh --shop-id <slug> --vps",
"# Optional: --env live|staging|playground|dev --image <registry/repo>",
"# --generate-app-secret --dry-run",
"# init-env copies this file \u2192 .env when .env is missing, then merges **missing**",
"# keys without clobbering existing non-empty values. It does not overwrite the",
"# whole .env and does not invent MYSQL passwords or APP_URL.",
"#",
"# Generate APP_SECRET with: openssl rand -hex 32",
"# (or: bash deploy/init-env.sh --generate-app-secret)",
"# First-install admin vars are consumed by shopware-deployment-helper only on a fresh DB.",
"",
"# --- Image (Compose + CD) ----------------------------------------------------",
"# SHOPWARE_PACKAGES_TOKEN is a CI secret, not a Compose variable. Set it only",
"# if the shop uses packages.shopware.com (empty is fine \u2014 not required).",
"# TODO: registry/repo for this shop, e.g. ghcr.io/fyrst-dev/shop-name",
"IMAGE=",
"IMAGE_TAG=latest",
"# From shopware/docker (Flex copies docker/Dockerfile)",
"DOCKERFILE=docker/Dockerfile",
"PHP_VERSION=8.3",
"",
"# --- Shopware runtime -----------------------------------------------------------",
"APP_ENV=prod",
"APP_URL=",
"APP_SECRET=",
"# Optional: INSTANCE_ID=",
"",
"# mysql://USER:PASSWORD@HOST:3306/DATABASE",
"# Bundled service hostname is `mysql`. For DBaaS, use the provider host and",
"# remove/disable the `mysql` service in deploy/compose.yaml.",
"DATABASE_URL=",
"# DATABASE_SSL_CA=",
"# DATABASE_SSL_CERT=",
"# DATABASE_SSL_KEY=",
"# DATABASE_SSL_DONT_VERIFY_SERVER_CERT=",
"",
"# --- Bundled MySQL service (compose service `mysql`) -----------------------",
"# Unused when DATABASE_URL points at an external database.",
"MYSQL_DATABASE=shopware",
"MYSQL_USER=",
"MYSQL_PASSWORD=",
"MYSQL_ROOT_PASSWORD=",
"",
"# --- First install (deployment helper; ignored on updates) --------------------",
"INSTALL_LOCALE=en-GB",
"INSTALL_CURRENCY=EUR",
"INSTALL_ADMIN_USERNAME=",
"INSTALL_ADMIN_PASSWORD=",
"INSTALL_ADMIN_EMAIL=",
"# Defaults to APP_URL when empty",
"SALES_CHANNEL_URL=",
"",
"# --- Optional Redis (compose profile `redis`) ------------------------------",
"# REDIS_URL=redis://redis:6379",
"# PHP_SESSION_HANDLER=redis",
"# PHP_SESSION_SAVE_PATH=tcp://redis:6379",
"",
"# --- Identity (multi-shop + live/staging on one VPS) -------------------------",
"# Same SHOPWARE_SHOP_ID on live, staging, and the laptop. Deploy env differs",
"# per stack. Compose interpolates these directly (no COMPOSE_PROJECT_NAME or",
"# SHOPWARE_DATA_ROOT required):",
"#",
"# name: ${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV} # e.g. acme-live",
"# bind mounts:",
"# ${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/...",
"#",
"# SHOPWARE_SHOP_ID=acme",
"# SHOPWARE_DEPLOY_ENV=live # live | staging | playground | \u2026",
"#",
"SHOPWARE_SHOP_ID=",
"SHOPWARE_DEPLOY_ENV=",
"# Optional bind-mount prefix (Compose default /var/lib/shopware/data):",
"# SHOPWARE_DATA_BASE=/var/lib/shopware/data",
"# Create once on the VPS (uid 82 = www-data):",
"# mkdir -p \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/$SHOPWARE_SHOP_ID/$SHOPWARE_DEPLOY_ENV\"/{files,media,thumbnail,theme,sitemap}",
"# chown -R 82:82 \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/$SHOPWARE_SHOP_ID/$SHOPWARE_DEPLOY_ENV\"",
"# mysql/redis stay named volumes (mysql_data, redis_data).",
"# Optional script helpers (Compose does not require these).",
"# Leave unset: vps-release / sync derive the same formulas. If set, scripts prefer",
"# these values. Do not assign them empty (COMPOSE_PROJECT_NAME= would override",
"# Compose's derived project name).",
"#",
"# WARNING: shopware-cli project create writes COMPOSE_PROJECT_NAME=sw-shop-\u2026",
"# into the shop-root `.env` for local `project dev`. That env var **overrides**",
"# Compose `name:` (`${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}`). On the VPS,",
"# comment out that line (`bash deploy/init-env.sh --vps` or by hand) and rely",
"# on shop id + deploy env. Flex does **not** delete it on composer require",
"# (create owns the local flow).",
"# COMPOSE_PROJECT_NAME=acme-live",
"# SHOPWARE_DATA_ROOT=/var/lib/shopware/data/acme/live",
"",
"# --- Optional HTTP publish (local / VPS behind a reverse proxy) ---------------",
"HTTP_PORT=8000",
"",
"# --- Optional VPS smoke check (deploy/vps-release.sh) ------------------------",
"# SMOKE_URL=http://127.0.0.1:8000",
"",
"# --- VPS image pull (deploy/compose.vps.yaml) --------------------------------",
"# Default pull_policy is always (CI/VPS after CI pushed IMAGE:IMAGE_TAG).",
"# Same-host tag-and-load / air-gap never pushed that tag, so pull fails.",
"# PULL_POLICY=never",
"# SKIP_PULL=1 # also skip `docker compose pull` in vps-release.sh",
"# # same as: bash deploy/vps-release.sh --skip-pull",
"# Leave unset on a real VPS that should pull from the registry.",
"# PULL_POLICY=never",
"",
"# --- Optional Compose profiles on the VPS (comma-separated) ------------------",
"# COMPOSE_PROFILES=redis,worker,scheduler",
"",
"# --- Optional VPS runtime data sync (deploy/sync-runtime.sh) -----------------",
"# Copy deploy/sync.env.example \u2192 deploy/sync.env (mode 0600). SSH and SYNC_ENV",
"# live there, not here. Object storage is out of scope for this VPS path.",
"# SYNC_ENV=staging",
""
],
"executable": false
},
"root/.github/workflows/cd.yaml": {
"contents": [
"# fyrst.dev Shopware CD \u2014 GitHub Actions",
"#",
"# Stages (same as .gitlab-ci.yaml): build \u2192 push extra tags \u2192 deploy.",
"# shopware-cli runs only inside docker/Dockerfile (shopware-cli image), not on the runner.",
"#",
"# Copied into shops by the Symfony Flex recipe (`copy-from-recipe` \u2192 shop root).",
"# Preflight runs CD only for a real Shopware shop (bin/console / shopware/*),",
"# never for the fyrst/shopware-cd Packagist library itself.",
"#",
"# ---------------------------------------------------------------------------",
"# CI secrets (Settings \u2192 Secrets and variables \u2192 Actions)",
"# ---------------------------------------------------------------------------",
"# Optional build-time (a shop can create/build/deploy without these):",
"# SHOPWARE_PACKAGES_TOKEN set only if the shop uses packages.shopware.com",
"# (BuildKit secret packages_token; empty is fine)",
"# COMPOSER_AUTH JSON object for private Composer repos",
"#",
"# Registry \u2014 GHCR default (GITHUB_TOKEN). For another registry set:",
"# REGISTRY_USERNAME",
"# REGISTRY_PASSWORD",
"# Optional variables (not secrets):",
"# REGISTRY default ghcr.io",
"# REGISTRY_IMAGE default ghcr.io/<owner>/<repo>",
"# DOCKERFILE default docker/Dockerfile (from shopware/docker)",
"#",
"# Compose / VPS deploy (DEPLOY_TARGET empty or \"compose\"):",
"# SSH_PRIVATE_KEY deploy key (no passphrase)",
"# VPS_HOST hostname or IP \u2014 set per shop, do not hardcode here",
"# VPS_USER",
"# VPS_PATH absolute checkout path on the VPS",
"# SSH_KNOWN_HOSTS recommended (ssh-keyscan output). If unset, keyscan is used.",
"# VPS_SSH_PORT optional, default 22 (repository variable)",
"# Compose files (via deploy/vps-release.sh, --project-directory = shop root):",
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
"#",
"# Optional:",
"# DEPLOY_TARGET repository variable: \"managed\" skips SSH Compose",
"# (see deploy/managed/README.md)",
"# SMOKE_URL public URL CI can GET after deploy",
"#",
"# Never put real tokens, keys, or hostnames in this file.",
"",
"name: CD",
"",
"on:",
" push:",
" branches: [main]",
" tags: [\"v*\"]",
" pull_request:",
" branches: [main]",
" workflow_dispatch:",
" inputs:",
" image_tag:",
" description: \"Override IMAGE_TAG (default: github.sha)\"",
" required: false",
"",
"concurrency:",
" group: cd-${{ github.workflow }}-${{ github.ref }}",
" cancel-in-progress: ${{ github.ref_name != 'main' }}",
"",
"permissions:",
" contents: read",
" packages: write",
"",
"env:",
" REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || 'ghcr.io' }}",
" IMAGE: ${{ vars.REGISTRY_IMAGE != '' && vars.REGISTRY_IMAGE || format('ghcr.io/{0}', github.repository) }}",
" DOCKERFILE: ${{ vars.DOCKERFILE != '' && vars.DOCKERFILE || 'docker/Dockerfile' }}",
" PHP_VERSION: \"8.3\"",
"",
"jobs:",
" preflight:",
" name: Preflight",
" runs-on: ubuntu-latest",
" outputs:",
" is_shop: ${{ steps.check.outputs.is_shop }}",
" steps:",
" - uses: actions/checkout@v4",
" - id: check",
" run: |",
" set -euo pipefail",
" is_shop=false",
" if [ -f composer.json ] && grep -q '\"name\": *\"fyrst/shopware-cd\"' composer.json; then",
" echo \"fyrst/shopware-cd package \u2014 skip shop CD.\"",
" elif [ -f bin/console ]; then",
" is_shop=true",
" elif [ -f composer.json ] && grep -Eq '\"shopware/' composer.json; then",
" is_shop=true",
" else",
" echo \"Not a Shopware shop \u2014 skip CD.\"",
" fi",
" echo \"is_shop=${is_shop}\" >> \"$GITHUB_OUTPUT\"",
"",
" build:",
" name: Build",
" needs: [preflight]",
" if: needs.preflight.outputs.is_shop == 'true'",
" runs-on: ubuntu-latest",
" timeout-minutes: 60",
" steps:",
" - name: Checkout",
" uses: actions/checkout@v4",
"",
" - name: Set up Docker Buildx",
" uses: docker/setup-buildx-action@v3",
"",
" - name: Log in to registry",
" if: github.event_name != 'pull_request'",
" uses: docker/login-action@v3",
" with:",
" registry: ${{ env.REGISTRY }}",
" username: ${{ secrets.REGISTRY_USERNAME || github.actor }}",
" password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}",
"",
" - name: Write Composer auth.json",
" env:",
" COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}",
" run: |",
" if [ -n \"$COMPOSER_AUTH\" ]; then",
" printf '%s\\n' \"$COMPOSER_AUTH\" > auth.json",
" else",
" printf '%s\\n' '{}' > auth.json",
" fi",
"",
" - name: Build and push :sha",
" uses: docker/build-push-action@v6",
" with:",
" context: .",
" file: ${{ env.DOCKERFILE }}",
" push: ${{ github.event_name != 'pull_request' }}",
" tags: ${{ env.IMAGE }}:${{ github.sha }}",
" platforms: linux/amd64",
" provenance: false",
" build-args: |",
" PHP_VERSION=${{ env.PHP_VERSION }}",
" # Empty SHOPWARE_PACKAGES_TOKEN is fine (optional packages.shopware.com).",
" secrets: |",
" packages_token=${{ secrets.SHOPWARE_PACKAGES_TOKEN }}",
" secret-files: |",
" composer_auth=auth.json",
" cache-from: type=gha",
" cache-to: type=gha,mode=max",
"",
" push:",
" name: Push",
" needs: [preflight, build]",
" if: needs.preflight.outputs.is_shop == 'true' && github.event_name != 'pull_request'",
" runs-on: ubuntu-latest",
" timeout-minutes: 15",
" steps:",
" - name: Log in to registry",
" uses: docker/login-action@v3",
" with:",
" registry: ${{ env.REGISTRY }}",
" username: ${{ secrets.REGISTRY_USERNAME || github.actor }}",
" password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}",
"",
" - name: Set up Docker Buildx",
" uses: docker/setup-buildx-action@v3",
"",
" - name: Tag :latest on main",
" if: github.ref == 'refs/heads/main'",
" run: docker buildx imagetools create -t \"${IMAGE}:latest\" \"${IMAGE}:${GITHUB_SHA}\"",
"",
" - name: Tag :semver on v* tags",
" if: startsWith(github.ref, 'refs/tags/v')",
" run: |",
" set -euo pipefail",
" VERSION=\"${GITHUB_REF_NAME#v}\"",
" docker buildx imagetools create -t \"${IMAGE}:${VERSION}\" \"${IMAGE}:${GITHUB_SHA}\"",
" MAJOR_MINOR=\"${VERSION%.*}\"",
" docker buildx imagetools create -t \"${IMAGE}:${MAJOR_MINOR}\" \"${IMAGE}:${GITHUB_SHA}\"",
"",
" deploy:",
" name: Deploy (Compose / VPS)",
" needs: [preflight, push]",
" if: >",
" needs.preflight.outputs.is_shop == 'true' &&",
" github.event_name != 'pull_request' &&",
" (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) &&",
" vars.DEPLOY_TARGET != 'managed'",
" runs-on: ubuntu-latest",
" timeout-minutes: 30",
" # TODO: rename or remove if the shop does not use GitHub Environments",
" environment: production",
" env:",
" IMAGE_TAG: ${{ github.event.inputs.image_tag || github.sha }}",
" VPS_HOST: ${{ secrets.VPS_HOST }}",
" VPS_USER: ${{ secrets.VPS_USER }}",
" VPS_PATH: ${{ secrets.VPS_PATH }}",
" VPS_SSH_PORT: ${{ vars.VPS_SSH_PORT != '' && vars.VPS_SSH_PORT || '22' }}",
" SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}",
" SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}",
" SMOKE_URL: ${{ secrets.SMOKE_URL }}",
" steps:",
" - name: Deploy over SSH",
" run: |",
" set -euo pipefail",
" : \"${VPS_HOST:?Set secret VPS_HOST}\"",
" : \"${VPS_USER:?Set secret VPS_USER}\"",
" : \"${VPS_PATH:?Set secret VPS_PATH}\"",
" : \"${SSH_PRIVATE_KEY:?Set secret SSH_PRIVATE_KEY}\"",
"",
" mkdir -p ~/.ssh",
" chmod 700 ~/.ssh",
" printf '%s\\n' \"$SSH_PRIVATE_KEY\" > ~/.ssh/id_deploy",
" chmod 600 ~/.ssh/id_deploy",
"",
" if [ -n \"${SSH_KNOWN_HOSTS:-}\" ]; then",
" printf '%s\\n' \"$SSH_KNOWN_HOSTS\" > ~/.ssh/known_hosts",
" chmod 600 ~/.ssh/known_hosts",
" else",
" echo \"SSH_KNOWN_HOSTS unset; using ssh-keyscan (TODO: pin known_hosts)\" >&2",
" ssh-keyscan -p \"$VPS_SSH_PORT\" -H \"$VPS_HOST\" >> ~/.ssh/known_hosts",
" fi",
"",
" ssh -i ~/.ssh/id_deploy -p \"$VPS_SSH_PORT\" -o IdentitiesOnly=yes \\",
" \"${VPS_USER}@${VPS_HOST}\" \\",
" \"set -euo pipefail",
" cd '${VPS_PATH}'",
" git fetch --quiet origin",
" git checkout --detach '${IMAGE_TAG}'",
" export IMAGE='${IMAGE}' IMAGE_TAG='${IMAGE_TAG}' COMPOSE_DIR='${VPS_PATH}' SMOKE_URL='${SMOKE_URL:-}'",
" bash ./deploy/vps-release.sh\"",
"",
" - name: Optional CI-side smoke",
" if: env.SMOKE_URL != ''",
" run: |",
" set -euo pipefail",
" for i in $(seq 1 20); do",
" if curl -fsS \"$SMOKE_URL\" >/dev/null; then",
" echo \"Smoke OK\"",
" exit 0",
" fi",
" sleep 3",
" done",
" echo \"Smoke check failed for ${SMOKE_URL}\" >&2",
" exit 1",
"",
" deploy_managed:",
" name: Deploy (managed host)",
" needs: [preflight, push]",
" if: >",
" needs.preflight.outputs.is_shop == 'true' &&",
" github.event_name != 'pull_request' &&",
" (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) &&",
" vars.DEPLOY_TARGET == 'managed'",
" runs-on: ubuntu-latest",
" timeout-minutes: 15",
" environment: production",
" steps:",
" - name: Managed deploy stub",
" env:",
" IMAGE_TAG: ${{ github.event.inputs.image_tag || github.sha }}",
" run: |",
" echo \"DEPLOY_TARGET=managed \u2014 same image, different last mile.\"",
" echo \"Image: ${IMAGE}:${IMAGE_TAG}\"",
" echo \"Replace this job with the host CLI/API. See deploy/managed/README.md.\"",
" echo \"Setup command stays:\"",
" echo \" vendor/bin/shopware-deployment-helper run --skip-theme-compile --skip-assets-install\"",
" exit 1",
""
],
"executable": false
},
"root/.gitlab-ci.yaml": {
"contents": [
"# fyrst.dev Shopware CD \u2014 GitLab CI",
"#",
"# Stages (same as .github/workflows/cd.yaml): build \u2192 push extra tags \u2192 deploy.",
"# shopware-cli runs only inside docker/Dockerfile (shopware-cli image), not on the runner.",
"#",
"# Copied into shops by the Symfony Flex recipe (`copy-from-recipe` \u2192 shop root).",
"# Workflow rules require `bin/console` so this Packagist library never runs",
"# shop CD (it has composer.json but no Shopware console).",
"#",
"# GitLab still looks for `.gitlab-ci.yml` by default. After Flex copies this file,",
"# set Settings \u2192 CI/CD \u2192 CI/CD configuration file to `.gitlab-ci.yaml`.",
"#",
"# ---------------------------------------------------------------------------",
"# CI/CD variables (Settings \u2192 CI/CD \u2192 Variables). Mask tokens. Never commit them.",
"# ---------------------------------------------------------------------------",
"# Optional build-time (a shop can create/build/deploy without these):",
"# SHOPWARE_PACKAGES_TOKEN set only if the shop uses packages.shopware.com",
"# (BuildKit secret packages_token; empty is fine)",
"# COMPOSER_AUTH JSON for private Composer repos",
"#",
"# Registry \u2014 GitLab Container Registry is the default (CI_REGISTRY_*).",
"# For GHCR or another registry, set:",
"# REGISTRY e.g. ghcr.io",
"# REGISTRY_IMAGE e.g. ghcr.io/fyrst-dev/shop-name",
"# REGISTRY_USERNAME",
"# REGISTRY_PASSWORD",
"# DOCKERFILE default docker/Dockerfile (from shopware/docker)",
"#",
"# Compose / VPS (DEPLOY_TARGET empty or \"compose\"):",
"# SSH_PRIVATE_KEY file or masked variable (PEM)",
"# VPS_HOST",
"# VPS_USER",
"# VPS_PATH",
"# SSH_KNOWN_HOSTS recommended",
"# VPS_SSH_PORT optional, default 22",
"# Compose files (via deploy/vps-release.sh, --project-directory = shop root):",
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
"#",
"# Optional:",
"# DEPLOY_TARGET \"managed\" skips SSH Compose (see deploy/managed/README.md)",
"# SMOKE_URL",
"#",
"# Runner: build uses Docker-in-Docker (privileged). Swap to kaniko/buildah",
"# if the runner cannot run dind \u2014 keep the same docker/Dockerfile and tags.",
"",
"workflow:",
" rules:",
" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"",
" exists:",
" - bin/console",
" - if: $CI_COMMIT_TAG",
" exists:",
" - bin/console",
" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS",
" when: never",
" - if: $CI_COMMIT_BRANCH",
" exists:",
" - bin/console",
"",
"stages:",
" - build",
" - push",
" - deploy",
"",
"default:",
" interruptible: true",
"",
"variables:",
" DOCKER_TLS_CERTDIR: \"/certs\"",
" DOCKER_BUILDKIT: \"1\"",
" PHP_VERSION: \"8.3\"",
" DOCKERFILE: \"docker/Dockerfile\"",
"",
".registry_env:",
" before_script:",
" - |",
" set -eu",
" export REGISTRY=\"${REGISTRY:-${CI_REGISTRY:-}}\"",
" export IMAGE=\"${REGISTRY_IMAGE:-${CI_REGISTRY_IMAGE}}\"",
" export REGISTRY_USERNAME=\"${REGISTRY_USERNAME:-${CI_REGISTRY_USER:-}}\"",
" export REGISTRY_PASSWORD=\"${REGISTRY_PASSWORD:-${CI_REGISTRY_PASSWORD:-}}\"",
" export DOCKERFILE=\"${DOCKERFILE:-docker/Dockerfile}\"",
" # Optional; empty if the shop does not use packages.shopware.com.",
" export SHOPWARE_PACKAGES_TOKEN=\"${SHOPWARE_PACKAGES_TOKEN:-}\"",
" : \"${IMAGE:?Set REGISTRY_IMAGE or use GitLab Container Registry}\"",
"",
"build:",
" stage: build",
" image: docker:27",
" services:",
" - docker:27-dind",
" timeout: 60m",
" needs: []",
" rules:",
" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"",
" - if: $CI_COMMIT_BRANCH",
" - if: $CI_COMMIT_TAG",
" before_script:",
" - !reference [.registry_env, before_script]",
" - docker info",
" - |",
" if [ -n \"${REGISTRY:-}\" ] && [ \"${CI_PIPELINE_SOURCE}\" != \"merge_request_event\" ]; then",
" echo \"$REGISTRY_PASSWORD\" | docker login -u \"$REGISTRY_USERNAME\" --password-stdin \"$REGISTRY\"",
" fi",
" - docker buildx create --use --name fyrst-ci --driver docker-container || docker buildx use fyrst-ci",
" - docker buildx inspect --bootstrap",
" - |",
" if [ -n \"${COMPOSER_AUTH:-}\" ]; then",
" printf '%s\\n' \"$COMPOSER_AUTH\" > auth.json",
" else",
" printf '%s\\n' '{}' > auth.json",
" fi",
" script:",
" - |",
" set -eu",
" PUSH_FLAG=\"\"",
" CACHE_TO=\"\"",
" if [ \"$CI_PIPELINE_SOURCE\" != \"merge_request_event\" ]; then",
" PUSH_FLAG=\"--push\"",
" CACHE_TO=\"--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max\"",
" fi",
" docker buildx build \\",
" --platform linux/amd64 \\",
" --file \"$DOCKERFILE\" \\",
" --build-arg PHP_VERSION=\"$PHP_VERSION\" \\",
" --secret id=packages_token,env=SHOPWARE_PACKAGES_TOKEN \\",
" --secret id=composer_auth,src=auth.json \\",
" --tag \"${IMAGE}:${CI_COMMIT_SHA}\" \\",
" --cache-from \"type=registry,ref=${IMAGE}:buildcache\" \\",
" $CACHE_TO \\",
" $PUSH_FLAG \\",
" .",
" after_script:",
" - rm -f auth.json",
"",
"push:",
" stage: push",
" image: docker:27",
" services:",
" - docker:27-dind",
" timeout: 15m",
" needs: [\"build\"]",
" rules:",
" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"",
" when: never",
" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
" - if: $CI_COMMIT_TAG",
" before_script:",
" - !reference [.registry_env, before_script]",
" - echo \"$REGISTRY_PASSWORD\" | docker login -u \"$REGISTRY_USERNAME\" --password-stdin \"$REGISTRY\"",
" - docker buildx create --use --name fyrst-ci --driver docker-container || docker buildx use fyrst-ci",
" script:",
" - |",
" set -eu",
" if [ \"${CI_COMMIT_BRANCH:-}\" = \"$CI_DEFAULT_BRANCH\" ]; then",
" docker buildx imagetools create -t \"${IMAGE}:latest\" \"${IMAGE}:${CI_COMMIT_SHA}\"",
" fi",
" if [ -n \"${CI_COMMIT_TAG:-}\" ]; then",
" VERSION=\"${CI_COMMIT_TAG#v}\"",
" docker buildx imagetools create -t \"${IMAGE}:${VERSION}\" \"${IMAGE}:${CI_COMMIT_SHA}\"",
" MAJOR_MINOR=\"${VERSION%.*}\"",
" docker buildx imagetools create -t \"${IMAGE}:${MAJOR_MINOR}\" \"${IMAGE}:${CI_COMMIT_SHA}\"",
" fi",
"",
"deploy:",
" stage: deploy",
" image: alpine:3.21",
" interruptible: false",
" timeout: 30m",
" needs: [\"push\"]",
" resource_group: production",
" rules:",
" - if: $DEPLOY_TARGET == \"managed\"",
" when: never",
" - if: $CI_COMMIT_TAG",
" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
" before_script:",
" - !reference [.registry_env, before_script]",
" - apk add --no-cache openssh-client bash",
" - |",
" set -eu",
" : \"${VPS_HOST:?Set CI variable VPS_HOST}\"",
" : \"${VPS_USER:?Set CI variable VPS_USER}\"",
" : \"${VPS_PATH:?Set CI variable VPS_PATH}\"",
" : \"${SSH_PRIVATE_KEY:?Set CI variable SSH_PRIVATE_KEY}\"",
" mkdir -p ~/.ssh",
" chmod 700 ~/.ssh",
" printf '%s\\n' \"$SSH_PRIVATE_KEY\" > ~/.ssh/id_deploy",
" chmod 600 ~/.ssh/id_deploy",
" PORT=\"${VPS_SSH_PORT:-22}\"",
" if [ -n \"${SSH_KNOWN_HOSTS:-}\" ]; then",
" printf '%s\\n' \"$SSH_KNOWN_HOSTS\" > ~/.ssh/known_hosts",
" chmod 600 ~/.ssh/known_hosts",
" else",
" echo \"SSH_KNOWN_HOSTS unset; using ssh-keyscan (TODO: pin known_hosts)\" >&2",
" ssh-keyscan -p \"$PORT\" -H \"$VPS_HOST\" >> ~/.ssh/known_hosts",
" fi",
" script:",
" - |",
" set -euo pipefail",
" PORT=\"${VPS_SSH_PORT:-22}\"",
" IMAGE_TAG=\"${CI_COMMIT_SHA}\"",
" ssh -i ~/.ssh/id_deploy -p \"$PORT\" -o IdentitiesOnly=yes \\",
" \"${VPS_USER}@${VPS_HOST}\" \\",
" \"set -euo pipefail",
" cd '${VPS_PATH}'",
" git fetch --quiet origin",
" git checkout --detach '${CI_COMMIT_SHA}'",
" export IMAGE='${IMAGE}' IMAGE_TAG='${IMAGE_TAG}' COMPOSE_DIR='${VPS_PATH}' SMOKE_URL='${SMOKE_URL:-}'",
" bash ./deploy/vps-release.sh\"",
" - |",
" if [ -n \"${SMOKE_URL:-}\" ]; then",
" apk add --no-cache curl",
" for i in $(seq 1 20); do",
" if curl -fsS \"$SMOKE_URL\" >/dev/null; then",
" echo \"Smoke OK\"",
" exit 0",
" fi",
" sleep 3",
" done",
" echo \"Smoke check failed for ${SMOKE_URL}\" >&2",
" exit 1",
" fi",
"",
"deploy_managed:",
" stage: deploy",
" image: alpine:3.21",
" interruptible: false",
" timeout: 15m",
" needs: [\"push\"]",
" resource_group: production",
" rules:",
" - if: $DEPLOY_TARGET == \"managed\" && $CI_COMMIT_TAG",
" - if: $DEPLOY_TARGET == \"managed\" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
" before_script:",
" - !reference [.registry_env, before_script]",
" script:",
" - |",
" echo \"DEPLOY_TARGET=managed \u2014 same image, different last mile.\"",
" echo \"Image: ${IMAGE}:${CI_COMMIT_SHA}\"",
" echo \"Replace this job with the host CLI/API. See deploy/managed/README.md.\"",
" echo \"Setup command stays:\"",
" echo \" vendor/bin/shopware-deployment-helper run --skip-theme-compile --skip-assets-install\"",
" exit 1",
""
],
"executable": false
},
"root/deploy/README.md": {
"contents": [
"# fyrst.dev \u2014 primary deploy: Docker Compose on a VPS",
"#",
"# Locked process: https://app.clickup.com/90151931897/docs/2kyqjkzt-915",
"# Image is built in CI (`shopware-cli project ci`). This host only pulls and runs it.",
"",
"## Model",
"",
"- **web** \u2014 Shopware image (`ghcr.io/shopware/docker-base` + project artifact), port 8000",
"- **setup** \u2014 one-shot `shopware-deployment-helper` (profile `setup`)",
"- **mysql** \u2014 bundled in Compose, or delete the service and point `DATABASE_URL` at DBaaS",
"- **redis** / **worker** / **scheduler** \u2014 optional Compose profiles",
"- **identity** \u2014 Compose `name: ${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}` and bind mounts under `${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}` (no `COMPOSE_PROJECT_NAME` / `SHOPWARE_DATA_ROOT` required, no hardcoded `name: shopware`)",
"- **WARNING:** `shopware-cli project create` writes `COMPOSE_PROJECT_NAME=sw-shop-\u2026` into shop-root `.env` for local `project dev`. That env var **overrides** Compose `name:`. On the VPS, **comment out** that line with `bash deploy/init-env.sh --vps` (or by hand). Flex does not delete it on `composer require` (create owns the local flow). `deploy/vps-release.sh` warns when the value does not match shop id + deploy env.",
"",
"## Shop-root `.env` after create (Flex + `deploy/init-env.sh`)",
"",
"`shopware-cli project create` writes `.env`. Flex may append a marked block",
"(safe defaults only \u2014 empty shop id, no secrets):",
"",
"```bash",
"###> fyrst/shopware-cd ###",
"SHOPWARE_SHOP_ID=",
"SHOPWARE_DEPLOY_ENV=live",
"SHOPWARE_DATA_BASE=/var/lib/shopware/data",
"###< fyrst/shopware-cd ###",
"```",
"",
"```bash",
"bash deploy/init-env.sh --shop-id acme",
"# VPS: bash deploy/init-env.sh --shop-id acme --env live --vps --image ghcr.io/example/acme",
"bash deploy/init-env.sh --shop-id acme --vps --dry-run",
"```",
"",
"`--shop-id` is required unless already non-empty. The script copies",
"`.env.example` \u2192 `.env` when `.env` is missing, merges missing keys, and does",
"not invent MYSQL passwords or `APP_URL`.",
"",
"## One-time VPS bootstrap",
"",
"1. Install Docker Engine + Compose plugin. Do not install Shopware or PHP on the host.",
"2. Checkout this shop repo (read-only deploy key) to a path such as `/opt/shopware/<shop>`.",
" That path is `VPS_PATH` in CI.",
"3. Finish `.env` with `bash deploy/init-env.sh --shop-id <slug> --vps` (Flex may already have appended SoT keys). `chmod 600 .env`.",
"4. Create `.env.prod` (may be empty) so `deploy/compose.prod.yaml` can mount it.",
"5. Set `IMAGE` to the registry repository CI pushes (example: `ghcr.io/fyrst-dev/shop-name`).",
"6. `docker login` to that registry on the VPS (or use a credential helper / `~/.docker/config.json`).",
"7. Put a reverse proxy in front of `HTTP_PORT` (TLS). Do not expose MySQL.",
"8. Store the previous image tag for rollback (the release script writes `.deployed-tag` / `.previous-tag`).",
"9. Runtime media/files bind-mount from",
" `${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}`.",
" Set `SHOPWARE_SHOP_ID` (same on live + staging) and `SHOPWARE_DEPLOY_ENV` in shop-root `.env`.",
" Optional `SHOPWARE_DATA_BASE` (default `/var/lib/shopware/data`). Compose does not require",
" `COMPOSE_PROJECT_NAME` or `SHOPWARE_DATA_ROOT`.",
" **Do not copy create\u2019s `COMPOSE_PROJECT_NAME=sw-shop-\u2026` onto the VPS.**",
" That line overrides Compose `name:`. Comment it out with",
" `bash deploy/init-env.sh --vps` (or by hand). Local `shopware-cli project dev`",
" can keep it; Flex does not delete it on `composer require` (create owns",
" the local flow).",
" Docker creates `{files,media,thumbnail,theme,sitemap}` on first up; `init-perm` chowns those dirs to uid 82.",
" Do not hardcode Compose `name: shopware`.",
"",
"## CD sequence (what CI runs)",
"",
"`deploy/vps-release.sh` (from the checkout at `VPS_PATH`):",
"",
"1. Record the currently deployed tag as `.previous-tag`",
"2. `docker compose \u2026 pull` the new `:git-sha` (skip with `SKIP_PULL=1` / `PULL_POLICY=never` / `--skip-pull`)",
"3. Start bundled `mysql` (if present) and optional profiles",
"4. Run setup **once**:",
"",
" ```bash",
" vendor/bin/shopware-deployment-helper run \\",
" --skip-theme-compile \\",
" --skip-assets-install",
" ```",
"",
" (via `docker compose --profile setup run --rm --pull never setup`)",
"5. Recreate `web` with `--no-build`",
"6. Optional `SMOKE_URL` check",
"",
"Manual equivalent:",
"",
"```bash",
"export IMAGE=ghcr.io/example-org/shop-name # TODO",
"export IMAGE_TAG=<full-git-sha>",
"",
"cd /opt/shopware/<shop> # TODO: VPS_PATH",
"git fetch --quiet origin",
"git checkout --quiet \"$IMAGE_TAG\"",
"",
"bash ./deploy/vps-release.sh",
"```",
"",
"Compose files used (from the shop root, with `--project-directory .`):",
"",
"- `deploy/compose.yaml`",
"- `deploy/compose.prod.yaml`",
"- `deploy/compose.vps.yaml` \u2014 `pull_policy: ${PULL_POLICY:-always}` (CI/VPS default). Same-host tag-and-load / air-gap: `PULL_POLICY=never` and `SKIP_PULL=1` (or `bash deploy/vps-release.sh --skip-pull`) so Compose does not pull a tag that was never pushed.",
"- `deploy/sync-runtime.sh` / `deploy/sync.env.example` / `deploy/sync-runtime.md` \u2014 live \u2192 lower VPS runtime copy (no S3)",
"- `deploy/sync-runtime-local.sh` \u2014 live `SHOPWARE_DATA_ROOT` \u2192 local `project dev` paths (rsync)",
"",
"shopware-cli project create owns shop-root `compose.yaml` (local). Do not point CD at that file. Create writes `.shopware-project.yml` (fine as-is; shopware-cli also accepts `.yaml` \u2014 do not rename). `compose run` uses `--pull never` (Compose v5 dropped `--no-build` from the run subcommand). `up` uses `--no-build`.",
"",
"## Why skip theme/assets on deploy",
"",
"`shopware-cli project ci` already compiled them into the image. Rebuilding on the VPS is an anti-pattern (time + drift).",
"",
"## Fresh install vs update",
"",
"The helper detects a fresh database vs an existing shop:",
"",
"- **Fresh:** schema, admin user from `INSTALL_ADMIN_*`, sales channel from `APP_URL` / `SALES_CHANNEL_URL`, extensions",
"- **Update:** migrations when the Shopware version changed, extension sync, hooks",
"",
"## Rollback",
"",
"```bash",
"export IMAGE_TAG=$(cat .previous-tag)",
"bash ./deploy/vps-release.sh",
"```",
"",
"Keep the previous image physically on the host (`docker image prune` with care).",
"",
"## Runtime data sync (VPS, no S3)",
"",
"DB + media/files are **not** in git and **not** in the app image. They live in MySQL and in **bind-mounted host directories**:",
"",
"```text",
"/var/lib/shopware/data/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/{files,media,thumbnail,theme,sitemap}",
"```",
"",
"Set `SHOPWARE_SHOP_ID` + `SHOPWARE_DEPLOY_ENV` in shop-root `.env`. Compose interpolates the project name and bind-mount paths from those two (plus optional `SHOPWARE_DATA_BASE`). Sync scripts still derive `COMPOSE_PROJECT_NAME` / `SHOPWARE_DATA_ROOT` when those helpers are unset. `mysql_data` / `redis_data` stay named volumes (copy the database with mysqldump, not `mysql_data`).",
"",
"`deploy/sync-runtime.sh` copies that runtime data **live \u2192 lower** (staging / playground / dev) with **SSH + mysqldump + rsync of those host dirs**. Volume tars are only a fallback (no rsync, or a leftover named volume). There is no S3/MinIO path in this recipe.",
"",
"See **[sync-runtime.md](sync-runtime.md)** for flags, cron, and safety.",
"",
"| Command | What it does |",
"| --- | --- |",
"| `sync --from live` | Pull dump + bind-mount dirs from a higher env onto **this** host |",
"| `snapshot` | Write a local snapshot under `SYNC_SNAPSHOT_DIR` (rsync of host dirs) |",
"| `restore --snapshot <id>` | Restore a local snapshot onto this host |",
"",
"**Direction:** run `sync` on the consumer (cron on staging). Never auto-push into live. `SYNC_ENV=live` refuses `sync`.",
"",
"```bash",
"# on staging",
"cd /opt/shopware/acme-staging",
"# .env: SHOPWARE_SHOP_ID=acme SHOPWARE_DEPLOY_ENV=staging",
"cp deploy/sync.env.example deploy/sync.env # set SYNC_SSH_* , SYNC_ENV=staging",
"chmod 600 deploy/sync.env",
"",
"bash deploy/sync-runtime.sh sync --from live --data all",
"```",
"",
"`--data all` (default) is DB + bind-mount dirs; use `db` or `volumes` to limit. Both shops need this script in `deploy/` (Flex-update live as well as staging).",
"",
"Cron (staging):",
"",
"```cron",
"15 2 * * * cd /opt/shopware/acme-staging && bash deploy/sync-runtime.sh sync --from live --data all",
"```",
"",
"Local snapshot / restore (same host, still no S3):",
"",
"```bash",
"bash deploy/sync-runtime.sh snapshot --data all",
"bash deploy/sync-runtime.sh restore --snapshot <id> --data all",
"```",
"",
"After a live DB lands on staging, `.env` is left alone. Optional `SYNC_REWRITE_FROM_URL` / `SYNC_REWRITE_TO_URL` rewrites `sales_channel_domain.url`. Then set staging `APP_URL` as usual.",
"",
"## Local laptop (`shopware-cli project dev`)",
"",
"VPS bind mounts are **not** the local CLI paths (the whole project is mounted). Pull live trees into the checkout:",
"",
"```bash",
"bash deploy/sync-runtime-local.sh --from live --data all",
"# optional: --delete --dry-run",
"shopware-cli project console cache:clear",
"```",
"",
"`--from live` is an SSH host (often `Host live` in `~/.ssh/config`). Map: `media` \u2192 `public/media/`, `files` \u2192 `files/`, plus thumbnail/theme/sitemap. Laptop `.env` needs `SHOPWARE_SHOP_ID` (same as live); remote default is `/var/lib/shopware/data/${SHOPWARE_SHOP_ID}/live`. This is **not** `deploy/sync-runtime.sh`.",
"",
"## CI secrets (Compose path)",
"",
"See comments at the top of `.github/workflows/cd.yaml` and `.gitlab-ci.yaml`.",
"",
"Typical for deploy: `SSH_PRIVATE_KEY`, `VPS_HOST`, `VPS_USER`, `VPS_PATH`, `SSH_KNOWN_HOSTS`.",
"",
"`SHOPWARE_PACKAGES_TOKEN` is optional \u2014 set it only if the shop uses packages.shopware.com. Empty is fine.",
""
],
"executable": false
},
"root/deploy/compose.prod.yaml": {
"contents": [
"# Production / VPS overrides. Used as:",
"# docker compose --project-directory . \\",
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml ...",
"#",
"# Put secrets in `.env` (required) and optional extra keys in `.env.prod`.",
"# Create an empty `.env.prod` on the VPS if you do not need overrides.",
"# Never commit filled env files.",
"",
"services:",
" web:",
" restart: unless-stopped",
" env_file:",
" - .env",
" - .env.prod",
" ports:",
" # TODO: publish 8000 only on localhost and terminate TLS on a reverse proxy.",
" - \"${HTTP_PORT:-8000}:8000\"",
" deploy:",
" resources:",
" limits:",
" cpus: \"2\"",
" memory: 1G",
" reservations:",
" cpus: \"0.50\"",
" memory: 512M",
" logging:",
" driver: json-file",
" options:",
" max-size: \"10m\"",
" max-file: \"5\"",
"",
" mysql:",
" restart: unless-stopped",
" env_file:",
" - .env",
" - .env.prod",
" # Do not publish 3306 on a public interface.",
" ports: []",
" deploy:",
" resources:",
" limits:",
" cpus: \"1\"",
" memory: 1G",
" reservations:",
" memory: 256M",
"",
" redis:",
" restart: unless-stopped",
" deploy:",
" resources:",
" limits:",
" memory: 256M",
"",
" worker:",
" restart: unless-stopped",
" env_file:",
" - .env",
" - .env.prod",
" deploy:",
" replicas: 1",
" resources:",
" limits:",
" memory: 512M",
"",
" scheduler:",
" restart: unless-stopped",
" env_file:",
" - .env",
" - .env.prod",
"",
" setup:",
" env_file:",
" - .env",
" - .env.prod",
" restart: \"no\"",
"",
" init-perm:",
" restart: \"no\"",
""
],
"executable": false
},
"root/deploy/compose.vps.yaml": {
"contents": [
"# VPS-only Compose overlay. CI / deploy/vps-release.sh adds this file so the",
"# host never rebuilds the image (assets were compiled in CI).",
"#",
"# docker compose --project-directory . \\",
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml pull",
"# docker compose --project-directory . \\",
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml up -d --no-build",
"#",
"# pull_policy default is always \u2014 correct after CI pushes IMAGE:IMAGE_TAG to a",
"# registry. Same-host tag-and-load (local E2E / air-gap) never pushed that tag,",
"# so pull fails. Override without a second compose file:",
"# PULL_POLICY=never",
"# SKIP_PULL=1 # also skip `docker compose pull` in vps-release.sh",
"# Leave unset on a real VPS that should pull from the registry.",
"",
"services:",
" web:",
" image: ${IMAGE:?Set IMAGE}:${IMAGE_TAG:?Set IMAGE_TAG to the git SHA}",
" pull_policy: ${PULL_POLICY:-always}",
"",
" setup:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: ${PULL_POLICY:-always}",
"",
" init-perm:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: ${PULL_POLICY:-always}",
"",
" worker:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: ${PULL_POLICY:-always}",
"",
" scheduler:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: ${PULL_POLICY:-always}",
""
],
"executable": false
},
"root/deploy/compose.yaml": {
"contents": [
"# CD Compose stack for fyrst.dev Shopware shops (VPS).",
"# shopware-cli project create owns shop-root compose.yaml (local).",
"# Production overrides: deploy/compose.prod.yaml",
"# VPS pull/no-build: deploy/compose.vps.yaml",
"# Local-only tweaks: compose.override.yaml at the shop root (do not commit secrets)",
"#",
"# Copy .env.example \u2192 .env at the shop root and fill values. No real secrets belong here.",
"#",
"# From the shop root:",
"# docker compose --project-directory . \\",
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml ...",
"#",
"# Runtime files (media, theme, \u2026) are bind-mounted from",
"# ${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/{files,media,thumbnail,theme,sitemap}.",
"# Compose interpolates SHOPWARE_SHOP_ID and SHOPWARE_DEPLOY_ENV (required via :?).",
"# SHOPWARE_DATA_BASE defaults to /var/lib/shopware/data.",
"# COMPOSE_PROJECT_NAME and SHOPWARE_DATA_ROOT are not required for this file.",
"# Do not set COMPOSE_PROJECT_NAME= (empty) in .env \u2014 that env var overrides name:.",
"# shopware-cli project create writes COMPOSE_PROJECT_NAME=sw-shop-\u2026 into .env",
"# for local project dev. On the VPS, remove or comment out that line.",
"# mysql_data / redis_data stay named volumes, scoped by the project name.",
"#",
"# Bundled MySQL: service name `mysql` (DATABASE_URL host = mysql).",
"# External DB: set DATABASE_URL to the DBaaS DSN, delete or comment out",
"# the `mysql` service, and drop it from depends_on.",
"#",
"# Optional profiles:",
"# redis \u2014 Redis 7",
"# setup \u2014 one-shot shopware-deployment-helper (CD / first install)",
"# worker \u2014 messenger:consume",
"# scheduler \u2014 scheduled-task:run",
"#",
"# Flex recipes cannot use YAML anchors; app services repeat image/build/mounts.",
"# Project name is ${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV} (e.g. acme-live).",
"# Do not hardcode `name: shopware` \u2014 several shops and live+staging share a host.",
"",
"name: \"${SHOPWARE_SHOP_ID:?Set SHOPWARE_SHOP_ID in .env}-${SHOPWARE_DEPLOY_ENV:?Set SHOPWARE_DEPLOY_ENV in .env}\"",
"",
"services:",
" web:",
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
" build:",
" context: .",
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
" args:",
" PHP_VERSION: ${PHP_VERSION:-8.3}",
" env_file:",
" - .env",
" volumes:",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/sitemap:/var/www/html/public/sitemap\"",
" ports:",
" - \"${HTTP_PORT:-8000}:8000\"",
" depends_on:",
" mysql:",
" condition: service_healthy",
" required: false",
" healthcheck:",
" test: [\"CMD-SHELL\", \"php -r 'exit(0);'\"]",
" interval: 10s",
" timeout: 5s",
" retries: 12",
" start_period: 30s",
"",
" # One-shot install/update. Never compiles themes/assets (those are in the image).",
" # docker compose --profile setup run --rm --pull never setup",
" setup:",
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
" build:",
" context: .",
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
" args:",
" PHP_VERSION: ${PHP_VERSION:-8.3}",
" env_file:",
" - .env",
" volumes:",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/sitemap:/var/www/html/public/sitemap\"",
" profiles: [\"setup\"]",
" restart: \"no\"",
" depends_on:",
" mysql:",
" condition: service_healthy",
" required: false",
" init-perm:",
" condition: service_completed_successfully",
" required: false",
" entrypoint:",
" [",
" \"php\",",
" \"vendor/bin/shopware-deployment-helper\",",
" \"run\",",
" \"--skip-theme-compile\",",
" \"--skip-assets-install\",",
" ]",
"",
" # Directory ownership for uid 82 (www-data in docker-base). Same profile as setup.",
" init-perm:",
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
" build:",
" context: .",
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
" args:",
" PHP_VERSION: ${PHP_VERSION:-8.3}",
" env_file:",
" - .env",
" volumes:",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/sitemap:/var/www/html/public/sitemap\"",
" profiles: [\"setup\"]",
" user: \"root\"",
" restart: \"no\"",
" entrypoint:",
" [",
" \"chown\",",
" \"82:82\",",
" \"/var/www/html/files\",",
" \"/var/www/html/public/theme\",",
" \"/var/www/html/public/media\",",
" \"/var/www/html/public/thumbnail\",",
" \"/var/www/html/public/sitemap\",",
" ]",
"",
" mysql:",
" image: mysql:8.4",
" # Shopware docs often use mariadb:11.4 \u2014 swap the image if you prefer MariaDB.",
" command:",
" - --character-set-server=utf8mb4",
" - --collation-server=utf8mb4_unicode_ci",
" env_file:",
" - .env",
" environment:",
" MYSQL_DATABASE: ${MYSQL_DATABASE:-shopware}",
" MYSQL_USER: ${MYSQL_USER:?Set MYSQL_USER in .env}",
" MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD in .env}",
" MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env}",
" volumes:",
" - mysql_data:/var/lib/mysql",
" healthcheck:",
" test: [\"CMD\", \"mysqladmin\", \"ping\", \"-h\", \"127.0.0.1\"]",
" interval: 5s",
" timeout: 5s",
" retries: 20",
" start_period: 20s",
"",
" redis:",
" image: redis:7-alpine",
" profiles: [\"redis\"]",
" command: [\"redis-server\", \"--appendonly\", \"yes\"]",
" volumes:",
" - redis_data:/data",
" healthcheck:",
" test: [\"CMD\", \"redis-cli\", \"ping\"]",
" interval: 5s",
" timeout: 3s",
" retries: 10",
"",
" worker:",
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
" build:",
" context: .",
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
" args:",
" PHP_VERSION: ${PHP_VERSION:-8.3}",
" env_file:",
" - .env",
" volumes:",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/sitemap:/var/www/html/public/sitemap\"",
" profiles: [\"worker\"]",
" depends_on:",
" mysql:",
" condition: service_healthy",
" required: false",
" entrypoint:",
" [",
" \"php\",",
" \"bin/console\",",
" \"messenger:consume\",",
" \"async\",",
" \"low_priority\",",
" \"--time-limit=300\",",
" \"--memory-limit=512M\",",
" ]",
"",
" scheduler:",
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
" build:",
" context: .",
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
" args:",
" PHP_VERSION: ${PHP_VERSION:-8.3}",
" env_file:",
" - .env",
" volumes:",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/sitemap:/var/www/html/public/sitemap\"",
" profiles: [\"scheduler\"]",
" depends_on:",
" mysql:",
" condition: service_healthy",
" required: false",
" entrypoint: [\"php\", \"bin/console\", \"scheduled-task:run\"]",
"",
"volumes:",
" mysql_data:",
" redis_data:",
""
],
"executable": false
},
"root/deploy/init-env.sh": {
"contents": [
"#!/usr/bin/env bash",
"# Finish shop-root .env after shopware-cli project create + Flex.",
"#",
"# Flex may append a ###> fyrst/shopware-cd ### block with safe SoT defaults",
"# (empty SHOPWARE_SHOP_ID, SHOPWARE_DEPLOY_ENV=live, SHOPWARE_DATA_BASE).",
"# This script fills shop-specific values. It does not overwrite the whole",
"# file, does not invent MYSQL passwords / APP_URL, and does not put secrets",
"# in the Flex env block.",
"#",
"# Usage (from shop root, or COMPOSE_DIR=shop-root):",
"# bash deploy/init-env.sh --shop-id <slug> [--env live] [--vps] \u2026",
"#",
"# See deploy/README.md.",
"",
"set -euo pipefail",
"",
"case \"$-\" in",
" *x*)",
" printf 'ERROR: refusing to run with xtrace (credentials may be in .env)\\n' >&2",
" exit 1",
" ;;",
"esac",
"",
"umask 077",
"",
"SCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"",
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"${SCRIPT_DIR}/..\" && pwd)}\"",
"",
"log() { printf '==> %s\\n' \"$*\"; }",
"err() { printf 'ERROR: %s\\n' \"$*\" >&2; }",
"die() { err \"$@\"; exit 1; }",
"",
"usage() {",
" cat <<'EOF'",
"Usage: bash deploy/init-env.sh [options]",
"",
"Finish shop-root .env after create + Flex. Merges missing keys from",
".env.example, sets shop identity, and can strip create's",
"COMPOSE_PROJECT_NAME=sw-shop-\u2026 line on a VPS.",
"",
"Does not overwrite the whole .env. Does not invent MYSQL passwords or",
"APP_URL. Flex env only ships safe defaults (empty shop id).",
"",
"Options:",
" --shop-id <slug> Required unless SHOPWARE_SHOP_ID is already non-empty",
" --env <name> live | staging | playground | dev (default: live",
" when unset/empty; existing non-empty value is kept)",
" --image <repo> Set IMAGE (registry/repo). Unset leaves IMAGE as-is",
" --vps Comment out COMPOSE_PROJECT_NAME=\u2026 lines (create",
" footgun). Does not leave an empty COMPOSE_PROJECT_NAME=",
" --generate-app-secret",
" Set APP_SECRET with openssl rand -hex 32 if empty",
" --dry-run Print the summary; do not write .env",
" -h, --help Show this help",
"",
"Environment:",
" COMPOSE_DIR Shop checkout (default: parent of deploy/)",
"",
"Examples:",
" bash deploy/init-env.sh --shop-id acme",
" bash deploy/init-env.sh --shop-id acme --env live --vps --image ghcr.io/example/acme",
" bash deploy/init-env.sh --shop-id acme --generate-app-secret",
" bash deploy/init-env.sh --shop-id acme --vps --dry-run",
"EOF",
"}",
"",
"need_value() {",
" local flag=$1",
" local value=${2:-}",
" if [[ -z \"$value\" || \"$value\" == --* ]]; then",
" die \"${flag} requires a value\"",
" fi",
"}",
"",
"SHOP_ID_FLAG=\"\"",
"ENV_FLAG=\"\"",
"IMAGE_FLAG=\"\"",
"VPS=0",
"GENERATE_SECRET=0",
"DRY_RUN=0",
"WORK=\"\"",
"ORIG=\"\"",
"",
"cleanup() {",
" rm -f \"${WORK:-}\" \"${ORIG:-}\"",
"}",
"trap cleanup EXIT",
"",
"while [[ $# -gt 0 ]]; do",
" case \"$1\" in",
" --shop-id)",
" need_value \"$1\" \"${2:-}\"",
" SHOP_ID_FLAG=$2",
" shift 2",
" ;;",
" --shop-id=*)",
" SHOP_ID_FLAG=\"${1#*=}\"",
" shift",
" ;;",
" --env)",
" need_value \"$1\" \"${2:-}\"",
" ENV_FLAG=$2",
" shift 2",
" ;;",
" --env=*)",
" ENV_FLAG=\"${1#*=}\"",
" shift",
" ;;",
" --image)",
" need_value \"$1\" \"${2:-}\"",
" IMAGE_FLAG=$2",
" shift 2",
" ;;",
" --image=*)",
" IMAGE_FLAG=\"${1#*=}\"",
" shift",
" ;;",
" --vps)",
" VPS=1",
" shift",
" ;;",
" --generate-app-secret)",
" GENERATE_SECRET=1",
" shift",
" ;;",
" --dry-run)",
" DRY_RUN=1",
" shift",
" ;;",
" -h | --help)",
" usage",
" exit 0",
" ;;",
" -*)",
" die \"Unknown option: $1 (try --help)\"",
" ;;",
" *)",
" die \"Unexpected argument: $1 (try --help)\"",
" ;;",
" esac",
"done",
"",
"cd \"$COMPOSE_DIR\" || die \"Cannot cd to COMPOSE_DIR=${COMPOSE_DIR}\"",
"",
"ENV_FILE=\"${COMPOSE_DIR}/.env\"",
"EXAMPLE_FILE=\"${COMPOSE_DIR}/.env.example\"",
"",
"is_comment_or_blank() {",
" [[ \"$1\" =~ ^[[:space:]]*# ]] || [[ \"$1\" =~ ^[[:space:]]*$ ]]",
"}",
"",
"# Uncommented KEY= assignment (optional export). Prints 1 if present.",
"env_has_key() {",
" local file=$1 key=$2 line",
" [[ -f \"$file\" ]] || return 1",
" while IFS= read -r line || [[ -n \"$line\" ]]; do",
" is_comment_or_blank \"$line\" && continue",
" if [[ \"$line\" =~ ^[[:space:]]*(export[[:space:]]+)?${key}= ]]; then",
" return 0",
" fi",
" done <\"$file\"",
" return 1",
"}",
"",
"env_unquote() {",
" local v=$1",
" if [[ ${#v} -ge 2 && \"$v\" == \\\"*\\\" ]]; then",
" v=\"${v#\\\"}\"",
" v=\"${v%\\\"}\"",
" elif [[ ${#v} -ge 2 && \"$v\" == \\'*\\' ]]; then",
" v=\"${v#\\'}\"",
" v=\"${v%\\'}\"",
" fi",
" printf '%s' \"$v\"",
"}",
"",
"# Last uncommented assignment wins (Compose / Docker dotenv).",
"env_get() {",
" local file=$1 key=$2 val=\"\" line",
" [[ -f \"$file\" ]] || return 0",
" while IFS= read -r line || [[ -n \"$line\" ]]; do",
" is_comment_or_blank \"$line\" && continue",
" if [[ \"$line\" =~ ^[[:space:]]*(export[[:space:]]+)?${key}=(.*)$ ]]; then",
" val=\"$(env_unquote \"${BASH_REMATCH[2]}\")\"",
" fi",
" done <\"$file\"",
" printf '%s' \"$val\"",
"}",
"",
"env_set_key() {",
" local file=$1 key=$2 value=$3",
" local tmp line found=0",
" tmp=\"$(mktemp)\"",
" while IFS= read -r line || [[ -n \"$line\" ]]; do",
" if is_comment_or_blank \"$line\"; then",
" printf '%s\\n' \"$line\"",
" continue",
" fi",
" if [[ \"$line\" =~ ^[[:space:]]*(export[[:space:]]+)?${key}= ]]; then",
" if [[ \"$line\" =~ ^[[:space:]]*export[[:space:]]+ ]]; then",
" printf 'export %s=%s\\n' \"$key\" \"$value\"",
" else",
" printf '%s=%s\\n' \"$key\" \"$value\"",
" fi",
" found=1",
" continue",
" fi",
" printf '%s\\n' \"$line\"",
" done <\"$file\" >\"$tmp\"",
" if [[ \"$found\" -eq 0 ]]; then",
" if [[ -s \"$tmp\" ]] && [[ \"$(tail -c 1 \"$tmp\" 2>/dev/null || true)\" != $'\\n' ]]; then",
" printf '\\n' >>\"$tmp\"",
" fi",
" printf '%s=%s\\n' \"$key\" \"$value\" >>\"$tmp\"",
" fi",
" mv \"$tmp\" \"$file\"",
"}",
"",
"validate_shop_id() {",
" local slug=$1",
" if [[ ! \"$slug\" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then",
" die \"Invalid --shop-id '${slug}'. Use a lowercase slug (letters, digits, hyphens), e.g. acme.\"",
" fi",
"}",
"",
"validate_deploy_env() {",
" local name=$1",
" case \"$name\" in",
" live | staging | playground | dev) ;;",
" *)",
" die \"Invalid --env '${name}'. Use live, staging, playground, or dev.\"",
" ;;",
" esac",
"}",
"",
"comment_compose_project_name() {",
" local file=$1",
" local tmp line n=0",
" tmp=\"$(mktemp)\"",
" while IFS= read -r line || [[ -n \"$line\" ]]; do",
" if [[ \"$line\" =~ ^[[:space:]]*(export[[:space:]]+)?COMPOSE_PROJECT_NAME= ]]; then",
" printf '# %s # commented by deploy/init-env.sh --vps (restore for local project dev)\\n' \"$line\"",
" n=$((n + 1))",
" continue",
" fi",
" printf '%s\\n' \"$line\"",
" done <\"$file\" >\"$tmp\"",
" mv \"$tmp\" \"$file\"",
" printf '%s' \"$n\"",
"}",
"",
"merge_missing_from_example() {",
" local example=$1 dest=$2",
" local line key header=0",
" local -a added=()",
" while IFS= read -r line || [[ -n \"$line\" ]]; do",
" is_comment_or_blank \"$line\" && continue",
" if [[ \"$line\" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)= ]]; then",
" key=\"${BASH_REMATCH[2]}\"",
" if ! env_has_key \"$dest\" \"$key\"; then",
" if [[ \"$header\" -eq 0 ]]; then",
" printf '\\n# --- missing keys merged from .env.example by deploy/init-env.sh ---\\n' >>\"$dest\"",
" header=1",
" fi",
" printf '%s\\n' \"$line\" >>\"$dest\"",
" added+=(\"$key\")",
" fi",
" fi",
" done <\"$example\"",
" if [[ ${#added[@]} -gt 0 ]]; then",
" local IFS=,",
" printf '%s' \"${added[*]}\"",
" fi",
"}",
"",
"if [[ ! -f \"$ENV_FILE\" && ! -f \"$EXAMPLE_FILE\" ]]; then",
" die \"Missing ${ENV_FILE} and ${EXAMPLE_FILE}. Run this from the shop root after Flex copied .env.example (composer require fyrst/shopware-cd), or copy a shop .env into COMPOSE_DIR=${COMPOSE_DIR}.\"",
"fi",
"",
"COPIED=0",
"if [[ ! -f \"$ENV_FILE\" ]]; then",
" COPIED=1",
"fi",
"",
"WORK=\"$(mktemp)\"",
"ORIG=\"$(mktemp)\"",
"if [[ -f \"$ENV_FILE\" ]]; then",
" cp \"$ENV_FILE\" \"$WORK\"",
" cp \"$ENV_FILE\" \"$ORIG\"",
"else",
" cp \"$EXAMPLE_FILE\" \"$WORK\"",
" : >\"$ORIG\"",
"fi",
"",
"MERGED_KEYS=\"\"",
"if [[ -f \"$EXAMPLE_FILE\" ]]; then",
" MERGED_KEYS=\"$(merge_missing_from_example \"$EXAMPLE_FILE\" \"$WORK\")\"",
"fi",
"",
"EXISTING_SHOP_ID=\"$(env_get \"$WORK\" SHOPWARE_SHOP_ID)\"",
"EXISTING_DEPLOY_ENV=\"$(env_get \"$WORK\" SHOPWARE_DEPLOY_ENV)\"",
"EXISTING_APP_SECRET=\"$(env_get \"$WORK\" APP_SECRET)\"",
"EXISTING_IMAGE=\"$(env_get \"$WORK\" IMAGE)\"",
"",
"SHOP_ID=\"${SHOP_ID_FLAG:-$EXISTING_SHOP_ID}\"",
"if [[ -z \"$SHOP_ID\" ]]; then",
" die \"SHOPWARE_SHOP_ID is empty. Pass --shop-id <slug> (same slug on live, staging, and laptop).\"",
"fi",
"validate_shop_id \"$SHOP_ID\"",
"",
"DEPLOY_ENV=\"${ENV_FLAG:-$EXISTING_DEPLOY_ENV}\"",
"if [[ -z \"$DEPLOY_ENV\" ]]; then",
" DEPLOY_ENV=live",
"fi",
"validate_deploy_env \"$DEPLOY_ENV\"",
"",
"if [[ -n \"$IMAGE_FLAG\" ]]; then",
" if [[ \"$IMAGE_FLAG\" =~ [[:space:]] ]]; then",
" die \"Invalid --image '${IMAGE_FLAG}' (no whitespace).\"",
" fi",
"fi",
"",
"if [[ \"$GENERATE_SECRET\" -eq 1 && -z \"$EXISTING_APP_SECRET\" && \"$DRY_RUN\" -eq 0 ]]; then",
" if ! command -v openssl >/dev/null 2>&1; then",
" die \"--generate-app-secret needs openssl (openssl rand -hex 32).\"",
" fi",
" NEW_SECRET=\"$(openssl rand -hex 32)\"",
" if [[ ${#NEW_SECRET} -ne 64 ]]; then",
" die \"openssl rand -hex 32 did not return 32 bytes.\"",
" fi",
" env_set_key \"$WORK\" APP_SECRET \"$NEW_SECRET\"",
" GENERATED_SECRET=1",
"else",
" GENERATED_SECRET=0",
" if [[ \"$GENERATE_SECRET\" -eq 1 && -z \"$EXISTING_APP_SECRET\" && \"$DRY_RUN\" -eq 1 ]]; then",
" GENERATED_SECRET=1",
" fi",
"fi",
"",
"env_set_key \"$WORK\" SHOPWARE_SHOP_ID \"$SHOP_ID\"",
"env_set_key \"$WORK\" SHOPWARE_DEPLOY_ENV \"$DEPLOY_ENV\"",
"if [[ -n \"$IMAGE_FLAG\" ]]; then",
" env_set_key \"$WORK\" IMAGE \"$IMAGE_FLAG\"",
"fi",
"",
"VPS_COMMENTED=0",
"if [[ \"$VPS\" -eq 1 ]]; then",
" VPS_COMMENTED=\"$(comment_compose_project_name \"$WORK\")\"",
"fi",
"",
"if [[ \"$DRY_RUN\" -eq 0 ]]; then",
" cp \"$WORK\" \"$ENV_FILE\"",
" chmod 600 \"$ENV_FILE\"",
"fi",
"",
"# --- summary (never print secret values) -----------------------------------",
"if [[ \"$DRY_RUN\" -eq 1 ]]; then",
" log \"DRY-RUN (no write) COMPOSE_DIR=${COMPOSE_DIR}\"",
"else",
" log \"Updated ${ENV_FILE}\"",
"fi",
"",
"CHANGES=0",
"if [[ \"$COPIED\" -eq 1 ]]; then",
" printf ' copy .env.example \u2192 .env\\n'",
" CHANGES=1",
"fi",
"if [[ -n \"$MERGED_KEYS\" ]]; then",
" printf ' merge missing keys from .env.example: %s\\n' \"${MERGED_KEYS//,/, }\"",
" CHANGES=1",
"fi",
"if [[ \"$EXISTING_SHOP_ID\" != \"$SHOP_ID\" ]]; then",
" printf ' set SHOPWARE_SHOP_ID=%s\\n' \"$SHOP_ID\"",
" CHANGES=1",
"fi",
"if [[ \"$EXISTING_DEPLOY_ENV\" != \"$DEPLOY_ENV\" ]]; then",
" printf ' set SHOPWARE_DEPLOY_ENV=%s\\n' \"$DEPLOY_ENV\"",
" CHANGES=1",
"fi",
"if [[ -n \"$IMAGE_FLAG\" && \"$EXISTING_IMAGE\" != \"$IMAGE_FLAG\" ]]; then",
" printf ' set IMAGE=%s\\n' \"$IMAGE_FLAG\"",
" CHANGES=1",
"fi",
"if [[ \"$GENERATE_SECRET\" -eq 1 ]]; then",
" if [[ -n \"$EXISTING_APP_SECRET\" ]]; then",
" printf ' APP_SECRET already set; skipped --generate-app-secret\\n'",
" elif [[ \"$GENERATED_SECRET\" -eq 1 ]]; then",
" printf ' set APP_SECRET (openssl rand -hex 32; value not printed)\\n'",
" CHANGES=1",
" fi",
"fi",
"if [[ \"$VPS\" -eq 1 ]]; then",
" if [[ \"$VPS_COMMENTED\" -gt 0 ]]; then",
" printf ' commented %s COMPOSE_PROJECT_NAME=\u2026 line(s) (--vps)\\n' \"$VPS_COMMENTED\"",
" CHANGES=1",
" else",
" printf ' --vps: no uncommented COMPOSE_PROJECT_NAME=\u2026 lines\\n'",
" fi",
"fi",
"",
"if [[ \"$CHANGES\" -eq 0 ]]; then",
" printf ' no changes (already up to date)\\n'",
"fi",
"",
"printf ' left unchanged: MYSQL passwords, APP_URL (fill those by hand)\\n'",
"if [[ \"$DRY_RUN\" -eq 0 ]]; then",
" log \"chmod 600 .env\"",
"fi",
""
],
"executable": false
},
"root/deploy/managed/README.md": {
"contents": [
"# Optional deploy: managed container host",
"",
"Same Shopware **image** as the Compose/VPS path. Use `docker/Dockerfile` from `shopware/docker`. Do **not** add another Dockerfile.",
"",
"Locked process: [Shopware Create & Continuous Deploy](https://app.clickup.com/90151931897/docs/2kyqjkzt-915)",
"",
"## When to use",
"",
"The platform starts/restarts containers for you (managed Kubernetes-like runtimes, PaaS-style hosts, mittwald-style container hosting, etc.). CI still:",
"",
"1. Builds with `shopware-cli project ci` in the multi-stage `docker/Dockerfile`",
"2. Pushes `:sha` / `:latest` / `:semver`",
"3. Runs Deployment Helper as a one-shot/setup job against that image",
"",
"## What changes",
"",
"Only the **deploy job** (and maybe which registry you push to):",
"",
"- Push to the host\u2019s registry **or** let the host pull from yours",
"- Trigger their deploy API / CLI / UI instead of SSH + Compose",
"- Map runtime env (`APP_URL`, `DATABASE_URL`, `APP_SECRET`, `INSTALL_ADMIN_*`) in the host\u2019s secret store",
"",
"## CI switch",
"",
"Use **`DEPLOY_TARGET`** (repository variable, not a secret):",
"",
"| Value | Deploy job |",
"| --- | --- |",
"| unset / `compose` | Primary: SSH + Compose (`deploy/vps-release.sh`) |",
"| `managed` | Skip Compose SSH; run the managed job instead |",
"",
"(`DEPLY_TARGET` is a typo \u2014 do not use it.)",
"",
"GitHub: Actions variable `DEPLOY_TARGET`. GitLab: CI/CD variable `DEPLOY_TARGET`.",
"",
"## What to fill in per host (TODOs)",
"",
"- [ ] Registry URL the platform pulls from",
"- [ ] Deploy token / kubeconfig / host CLI credentials (CI secret)",
"- [ ] How to run the one-shot setup command with the **same** flags:",
"",
" ```bash",
" vendor/bin/shopware-deployment-helper run \\",
" --skip-theme-compile \\",
" --skip-assets-install",
" ```",
"",
"- [ ] Health/smoke URL after rollout",
"- [ ] Rollback: redeploy the previous `:sha` tag",
"",
"The managed jobs in `.github/workflows/cd.yaml` and `.gitlab-ci.yaml` are **stubs**: they fail with a clear message until you replace the script with the host\u2019s CLI. That is intentional \u2014 do not copy a fake happy-path.",
"",
"## Keep identical across hosts",
"",
"- `docker/Dockerfile` / `PHP_VERSION=8.3`",
"- `.shopware-project.yml` (create\u2019s default; `.yaml` also accepted \u2014 owned by `shopware-cli project create` / the CLI, not this recipe)",
"- CD Compose under `deploy/` (`compose.yaml`, `compose.prod.yaml`, `compose.vps.yaml`)",
"- Image naming and tags",
"- Setup command (deployment helper + skip flags)",
"- Optional build-time secrets (`SHOPWARE_PACKAGES_TOKEN` only if the shop uses packages.shopware.com; Composer auth). Empty token is fine.",
""
],
"executable": false
},
"root/deploy/sync-runtime-local.sh": {
"contents": [
"#!/usr/bin/env bash",
"# Pull live VPS SHOPWARE_DATA_ROOT trees into local `shopware-cli project dev` paths.",
"#",
"# Local CLI compose bind-mounts the whole project, so destinations are the shop",
"# tree (files/, public/media/, \u2026) \u2014 not VPS /var/lib/shopware/data/<shop>/<env>.",
"# VPS \u2192 VPS copy stays deploy/sync-runtime.sh on the consumer.",
"#",
"# Remote default (when unset / unprobed):",
"# /var/lib/shopware/data/${SHOPWARE_SHOP_ID}/${--from env}",
"# Laptop .env needs at least SHOPWARE_SHOP_ID (same as live).",
"#",
"# bash deploy/sync-runtime-local.sh --from live --data all",
"# bash deploy/sync-runtime-local.sh --from live --data all --delete --dry-run",
"#",
"# Do not run with `bash -x` \u2014 SSH identities / env may be sensitive.",
"",
"set -euo pipefail",
"",
"case \"$-\" in",
" *x*)",
" printf 'ERROR: refusing to run with xtrace (credentials may be in the environment)\\n' >&2",
" exit 1",
" ;;",
"esac",
"",
"umask 077",
"",
"SCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"",
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"${SCRIPT_DIR}/..\" && pwd)}\"",
"",
"DEFAULT_DATA=\"files,media,thumbnail,theme,sitemap\"",
"SHOPWARE_DATA_BASE=\"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}\"",
"",
"FROM=\"\"",
"DATA_SPEC=\"all\"",
"DRY_RUN=0",
"DELETE=0",
"",
"SSH_TARGET=\"\"",
"REMOTE_PATH=\"\"",
"REMOTE_DATA_ROOT=\"\"",
"WANT_VOLUMES=()",
"",
"log() { printf '==> %s\\n' \"$*\"; }",
"err() { printf 'ERROR: %s\\n' \"$*\" >&2; }",
"die() { err \"$@\"; exit 1; }",
"",
"usage() {",
" cat <<'EOF'",
"Usage: deploy/sync-runtime-local.sh --from <alias> [options]",
"",
"Copy bind-mounted Shopware runtime files from a VPS (typically live) into",
"this shop checkout for `shopware-cli project dev`.",
"",
" live $SHOPWARE_DATA_ROOT/files \u2192 ./files/",
" live $SHOPWARE_DATA_ROOT/media \u2192 ./public/media/",
" live $SHOPWARE_DATA_ROOT/thumbnail \u2192 ./public/thumbnail/",
" live $SHOPWARE_DATA_ROOT/theme \u2192 ./public/theme/",
" live $SHOPWARE_DATA_ROOT/sitemap \u2192 ./public/sitemap/",
"",
"Remote SHOPWARE_DATA_ROOT defaults to",
"/var/lib/shopware/data/${SHOPWARE_SHOP_ID}/<from-env> (usually live).",
"Set SHOPWARE_SHOP_ID in laptop .env (same slug as the VPS).",
"",
"This is not deploy/sync-runtime.sh (that script targets VPS SHOPWARE_DATA_ROOT",
"+ deploy Compose). Database dumps are out of scope here.",
"",
"Options:",
" --from <alias> SSH source (required). Default host is the alias",
" itself (e.g. Host live in ~/.ssh/config).",
" --data <list>|all Comma-separated subset, or \"all\" (default).",
" Items: files, media, thumbnail, theme, sitemap",
" --delete Pass rsync --delete (drops local-only files)",
" --dry-run Print planned rsyncs; do not connect or copy",
" -h, --help",
"",
"Environment (optional; deploy/sync.env is sourced when present):",
" SYNC_SSH_HOST Source hostname (default: the --from alias)",
" SYNC_SSH_USER SSH user (empty = ssh config / current user)",
" SYNC_SSH_PORT SSH port (default 22)",
" SYNC_SSH_KEY Identity file (SYNC_SSH_IDENTITY also accepted)",
" SYNC_REMOTE_PATH Shop checkout on the source (optional; used to",
" probe SHOPWARE_DATA_ROOT from the remote .env).",
" SYNC_SSH_PATH / SYNC_LIVE_PATH also accepted.",
" SYNC_REMOTE_DATA_ROOT Bind-mount root on the source (overrides derivation)",
" SHOPWARE_SHOP_ID Same slug as live; used to derive the remote root",
" SHOPWARE_DATA_ROOT Unused as a local destination (project-dev paths)",
"",
"Per-alias overrides (example --from live): SYNC_LIVE_SSH_HOST,",
"SYNC_LIVE_SSH_USER, SYNC_LIVE_SSH_PORT, SYNC_LIVE_SSH_KEY,",
"SYNC_LIVE_REMOTE_PATH, SYNC_LIVE_DATA_ROOT.",
"",
"Examples:",
" bash deploy/sync-runtime-local.sh --from live --data all",
" bash deploy/sync-runtime-local.sh --from live --data media,files --delete",
" bash deploy/sync-runtime-local.sh --from live --data all --dry-run",
"",
"After copy:",
" shopware-cli project console cache:clear",
"EOF",
"}",
"",
"need_value() {",
" local flag=$1",
" local value=${2:-}",
" if [[ -z \"$value\" || \"$value\" == --* ]]; then",
" die \"${flag} requires a value\"",
" fi",
"}",
"",
"while [[ $# -gt 0 ]]; do",
" case \"$1\" in",
" sync)",
" # Muscle memory from deploy/sync-runtime.sh; this script only pulls locally.",
" shift",
" ;;",
" snapshot | restore | export)",
" die \"Use deploy/sync-runtime.sh ${1} on a VPS. This script only rsyncs into local project-dev paths.\"",
" ;;",
" help | -h | --help)",
" usage",
" exit 0",
" ;;",
" --from)",
" need_value \"$1\" \"${2:-}\"",
" FROM=$2",
" shift 2",
" ;;",
" --from=*)",
" FROM=\"${1#*=}\"",
" shift",
" ;;",
" --data)",
" need_value \"$1\" \"${2:-}\"",
" DATA_SPEC=$2",
" shift 2",
" ;;",
" --data=*)",
" DATA_SPEC=\"${1#*=}\"",
" shift",
" ;;",
" --delete)",
" DELETE=1",
" shift",
" ;;",
" --dry-run)",
" DRY_RUN=1",
" shift",
" ;;",
" -*)",
" die \"Unknown option: $1 (try --help)\"",
" ;;",
" *)",
" die \"Unexpected argument: $1 (try --help)\"",
" ;;",
" esac",
"done",
"",
"if [[ -z \"$FROM\" ]]; then",
" usage",
" die \"Missing --from <alias> (e.g. --from live)\"",
"fi",
"",
"cd \"$COMPOSE_DIR\"",
"",
"load_env_file() {",
" local f=$1",
" if [[ -f \"$f\" ]]; then",
" set -a",
" # shellcheck disable=SC1090",
" source \"$f\"",
" set +a",
" fi",
"}",
"",
"load_env_file .env",
"load_env_file deploy/sync.env",
"SHOPWARE_DATA_BASE=\"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}\"",
"",
"derived_remote_data_root() {",
" local shop=\"${SHOPWARE_SHOP_ID:-}\"",
" local envn=\"${1:-${FROM_LC:-live}}\"",
" if [[ -n \"$shop\" ]]; then",
" printf '%s' \"${SHOPWARE_DATA_BASE}/${shop}/${envn}\"",
" else",
" printf '%s' \"$SHOPWARE_DATA_BASE\"",
" fi",
"}",
"",
"split_csv() {",
" local csv=$1",
" local IFS=','",
" # shellcheck disable=SC2086",
" set -- $csv",
" local item",
" for item in \"$@\"; do",
" item=\"${item// /}\"",
" [[ -n \"$item\" ]] && printf '%s\\n' \"$item\"",
" done",
"}",
"",
"normalize_data() {",
" local spec=$1",
" local item lower",
" local -a raw=()",
"",
" if [[ \"$spec\" == \"all\" ]]; then",
" spec=$DEFAULT_DATA",
" fi",
"",
" while IFS= read -r item; do",
" [[ -z \"$item\" ]] && continue",
" raw+=(\"$item\")",
" done < <(split_csv \"$spec\")",
"",
" if [[ ${#raw[@]} -eq 0 ]]; then",
" die \"--data is empty\"",
" fi",
"",
" WANT_VOLUMES=()",
" for item in \"${raw[@]}\"; do",
" lower=$(printf '%s' \"$item\" | tr '[:upper:]' '[:lower:]')",
" case \"$lower\" in",
" db | database | mysql)",
" die \"Database dumps are out of scope for sync-runtime-local.sh. Copy SQL separately into the CLI database service, or use deploy/sync-runtime.sh on a VPS.\"",
" ;;",
" files | media | thumbnail | theme | sitemap)",
" WANT_VOLUMES+=(\"$lower\")",
" ;;",
" mysql_data | redis_data | volumes)",
" die \"Unknown --data item '${item}'. Use files, media, thumbnail, theme, sitemap, or all.\"",
" ;;",
" *)",
" die \"Unknown --data item '${item}'. Use files, media, thumbnail, theme, sitemap, or all.\"",
" ;;",
" esac",
" done",
"",
" if [[ ${#WANT_VOLUMES[@]} -eq 0 ]]; then",
" die \"Nothing to copy\"",
" fi",
"}",
"",
"normalize_data \"$DATA_SPEC\"",
"",
"lower_s() { printf '%s' \"$1\" | tr '[:upper:]' '[:lower:]'; }",
"",
"FROM_LC=\"$(lower_s \"$FROM\")\"",
"",
"if [[ \"$FROM_LC\" == \"local\" || \"$FROM_LC\" == \"this\" ]]; then",
" die \"--from local is invalid here. Source must be a VPS alias (e.g. --from live).\"",
"fi",
"",
"alias_key() {",
" printf '%s' \"$1\" | tr '[:lower:]-' '[:upper:]_'",
"}",
"",
"pick_alias_env() {",
" local key=$1",
" local suffix=$2",
" local specific=\"SYNC_${key}_${suffix}\"",
" local general=\"SYNC_${suffix}\"",
" if [[ -n \"${!specific:-}\" ]]; then",
" printf '%s' \"${!specific}\"",
" else",
" printf '%s' \"${!general:-}\"",
" fi",
"}",
"",
"key=\"$(alias_key \"$FROM\")\"",
"host=\"$(pick_alias_env \"$key\" SSH_HOST)\"",
"user=\"$(pick_alias_env \"$key\" SSH_USER)\"",
"port=\"$(pick_alias_env \"$key\" SSH_PORT)\"",
"keyfile=\"$(pick_alias_env \"$key\" SSH_KEY)\"",
"if [[ -z \"$keyfile\" ]]; then",
" keyfile=\"$(pick_alias_env \"$key\" SSH_IDENTITY)\"",
"fi",
"REMOTE_PATH=\"$(pick_alias_env \"$key\" REMOTE_PATH)\"",
"if [[ -z \"$REMOTE_PATH\" ]]; then",
" REMOTE_PATH=\"$(pick_alias_env \"$key\" SSH_PATH)\"",
"fi",
"if [[ -z \"$REMOTE_PATH\" ]]; then",
" REMOTE_PATH=\"$(pick_alias_env \"$key\" PATH)\"",
"fi",
"specific_dr=\"SYNC_${key}_DATA_ROOT\"",
"REMOTE_DATA_ROOT=\"${!specific_dr:-${SYNC_REMOTE_DATA_ROOT:-}}\"",
"",
"if [[ -z \"$host\" ]]; then",
" host=$FROM",
"fi",
"SYNC_SSH_HOST=$host",
"SYNC_SSH_USER=$user",
"SYNC_SSH_PORT=\"${port:-22}\"",
"SYNC_SSH_KEY=$keyfile",
"",
"if [[ -n \"$SYNC_SSH_USER\" ]]; then",
" SSH_TARGET=\"${SYNC_SSH_USER}@${host}\"",
"else",
" SSH_TARGET=$host",
"fi",
"",
"SSH_CMD=(ssh -o BatchMode=yes -p \"${SYNC_SSH_PORT:-22}\")",
"if [[ -n \"${SYNC_SSH_KEY:-}\" ]]; then",
" SSH_CMD+=(-o IdentitiesOnly=yes -i \"${SYNC_SSH_KEY}\")",
"fi",
"",
"local_item_dir() {",
" local logical=$1",
" case \"$logical\" in",
" files) printf '%s/files\\n' \"$COMPOSE_DIR\" ;;",
" media) printf '%s/public/media\\n' \"$COMPOSE_DIR\" ;;",
" thumbnail) printf '%s/public/thumbnail\\n' \"$COMPOSE_DIR\" ;;",
" theme) printf '%s/public/theme\\n' \"$COMPOSE_DIR\" ;;",
" sitemap) printf '%s/public/sitemap\\n' \"$COMPOSE_DIR\" ;;",
" *) die \"No local project-dev path for ${logical}\" ;;",
" esac",
"}",
"",
"bind_item_dir() {",
" local root=$1",
" local logical=$2",
" printf '%s/%s\\n' \"$root\" \"$logical\"",
"}",
"",
"require_cmd() {",
" local c=$1",
" if ! command -v \"$c\" >/dev/null 2>&1; then",
" die \"Missing command '${c}'. Install rsync and OpenSSH client on this machine.\"",
" fi",
"}",
"",
"if [[ -n \"${SYNC_SSH_KEY:-}\" && ! -f \"${SYNC_SSH_KEY}\" ]]; then",
" die \"SYNC_SSH_KEY not found: ${SYNC_SSH_KEY}\"",
"fi",
"",
"require_cmd bash",
"if [[ \"$DRY_RUN\" -eq 0 ]]; then",
" require_cmd ssh",
" require_cmd rsync",
"fi",
"",
"remote_bash() {",
" local cmd=$1",
" local remote_cmd",
" if [[ -n \"${REMOTE_PATH}\" ]]; then",
" remote_cmd=\"set -euo pipefail; cd $(printf '%q' \"$REMOTE_PATH\"); if [ -f .env ]; then set -a; . ./.env; set +a; fi; ${cmd}\"",
" else",
" remote_cmd=\"set -euo pipefail; ${cmd}\"",
" fi",
" \"${SSH_CMD[@]}\" \"$SSH_TARGET\" \"$remote_cmd\"",
"}",
"",
"resolve_remote_data_root() {",
" if [[ -n \"${REMOTE_DATA_ROOT}\" ]]; then",
" return",
" fi",
" local fallback",
" fallback=\"$(derived_remote_data_root \"$FROM_LC\")\"",
" if [[ -z \"${SHOPWARE_SHOP_ID:-}\" && -z \"${REMOTE_DATA_ROOT}\" ]]; then",
" log \"SHOPWARE_SHOP_ID unset; remote root fallback ${fallback}. Set SHOPWARE_SHOP_ID in .env (same as live).\"",
" fi",
" if [[ \"$DRY_RUN\" -eq 1 ]]; then",
" REMOTE_DATA_ROOT=$fallback",
" log \"DRY-RUN remote SHOPWARE_DATA_ROOT default ${REMOTE_DATA_ROOT} (probe skipped)\"",
" return",
" fi",
" local probed=\"\" remote_printf",
" if [[ -n \"${REMOTE_PATH}\" ]]; then",
" # shellcheck disable=SC2016",
" remote_printf='printf %s \"${SYNC_DATA_ROOT:-${SHOPWARE_DATA_ROOT:-}}\"'",
" probed=\"$(remote_bash \"$remote_printf\" || true)\"",
" probed=\"$(printf '%s' \"$probed\" | tr -d '\\r' | tail -n 1)\"",
" fi",
" if [[ -n \"$probed\" ]]; then",
" REMOTE_DATA_ROOT=$probed",
" else",
" REMOTE_DATA_ROOT=$fallback",
" fi",
" log \"Remote bind-mount root: ${REMOTE_DATA_ROOT}\"",
"}",
"",
"rsync_from_remote_tree() {",
" local remote_dir=$1",
" local dest=$2",
" local -a args",
" args=(-az --no-owner --no-group -e \"${SSH_CMD[*]}\")",
" if [[ \"$DELETE\" -eq 1 ]]; then",
" args+=(--delete)",
" fi",
" mkdir -p \"$dest\"",
" rsync \"${args[@]}\" \"${SSH_TARGET}:${remote_dir%/}/\" \"${dest%/}/\"",
"}",
"",
"sync_item() {",
" local logical=$1",
" local src dest",
" src=\"$(bind_item_dir \"$REMOTE_DATA_ROOT\" \"$logical\")\"",
" dest=\"$(local_item_dir \"$logical\")\"",
" log \"Rsync ${SSH_TARGET}:${src}/ \u2192 ${dest}/\"",
" if [[ \"$DRY_RUN\" -eq 1 ]]; then",
" if [[ \"$DELETE\" -eq 1 ]]; then",
" log \"DRY-RUN rsync -az --delete ${SSH_TARGET}:${src}/ ${dest}/\"",
" else",
" log \"DRY-RUN rsync -az ${SSH_TARGET}:${src}/ ${dest}/\"",
" fi",
" return",
" fi",
" rsync_from_remote_tree \"$src\" \"$dest\"",
"}",
"",
"resolve_remote_data_root",
"",
"log \"Local project-dev pull from=${FROM} data=$(IFS=,; printf '%s' \"${WANT_VOLUMES[*]}\") delete=${DELETE} dry-run=${DRY_RUN}\"",
"",
"item=\"\"",
"for item in \"${WANT_VOLUMES[@]}\"; do",
" sync_item \"$item\"",
"done",
"",
"if [[ \"$DRY_RUN\" -eq 1 ]]; then",
" log \"Dry-run finished (no files copied)\"",
" exit 0",
"fi",
"",
"log \"Copy finished ${FROM} \u2192 ${COMPOSE_DIR} (project-dev paths)\"",
"log \"Next: shopware-cli project console cache:clear\"",
"log \"These dirs stay gitignored \u2014 never commit them.\"",
""
],
"executable": true
},
"root/deploy/sync-runtime.md": {
"contents": [
"# Runtime data sync (VPS, bind mounts, no object storage)",
"",
"Pull **database + Shopware runtime files** from another VPS onto this one. Typical direction: **live \u2192 staging / playground / dev**.",
"",
"This is **not** part of image CD. `deploy/vps-release.sh` is unchanged (pull image, setup helper, recreate `web`). Runtime files stay out of git and out of the Shopware app image (`/.dockerignore` already excludes `/deploy` and `/var`).",
"",
"Object storage (S3 and similar) is **out of scope** for this VPS path. Transfer is SSH + `mysqldump` + **rsync of host bind-mount directories**. Named-volume tars are only a fallback (no `rsync` on the consumer, or a leftover Docker volume from an older stack).",
"",
"## What is copied",
"",
"Default `--data all` (same as omitting `--data`):",
"",
"| Item | Mechanism |",
"| --- | --- |",
"| `db` | Logical SQL dump from the bundled compose `mysql` service (or `DATABASE_URL`) |",
"| `media` `files` `thumbnail` `theme` `sitemap` | Bind mounts under `${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/<name>` |",
"",
"Default host root (when `SHOPWARE_DATA_ROOT` is unset, derived by this script):",
"",
"```text",
"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}",
"```",
"",
"Set `SHOPWARE_SHOP_ID` (same slug on live + staging) and `SHOPWARE_DEPLOY_ENV` in shop-root `.env`. Compose interpolates those two plus optional `SHOPWARE_DATA_BASE`; it does not require `COMPOSE_PROJECT_NAME` or `SHOPWARE_DATA_ROOT`. If the **source** uses a non-derived root, set `SYNC_REMOTE_DATA_ROOT` or `SYNC_LIVE_DATA_ROOT` in `deploy/sync.env`. Otherwise the source path is `${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/<from-env>`.",
"",
"Not copied: `mysql_data` / `redis_data` named volumes (use `db` for SQL; Redis is ephemeral for this recipe). Do not put dumps in git.",
"",
"## Host packages",
"",
"On **every** VPS that snapshots or restores:",
"",
"- Docker Engine + Compose v2 plugin",
"- bash",
"- OpenSSH client",
"- gzip",
"- **rsync** (primary copy path for bind-mount dirs)",
"",
"The SSH user must be able to run `docker` (typically the `docker` group) so restore can `chown` uid 82 via a one-shot Alpine container.",
"",
"## One-time setup (consumer)",
"",
"On staging (or playground/dev), not on live:",
"",
"1. Copy `deploy/sync.env.example` \u2192 `deploy/sync.env` and `chmod 600 deploy/sync.env`.",
"2. Set `SYNC_ENV=staging` (or `playground` / `dev`). **Never** set `SYNC_ENV=live` on a host you restore onto.",
"3. Fill `SYNC_SSH_*` and `SYNC_SSH_PATH` for the source (live checkout, e.g. `/opt/shopware/live`).",
"4. Install an SSH key that can log in to live **without a passphrase** (cron). Pin `known_hosts`.",
"5. Confirm shop-root `.env` has `IMAGE`, `SHOPWARE_SHOP_ID`, and `SHOPWARE_DEPLOY_ENV`. Compose interpolates project name and bind-mount paths from those. Sync derives `COMPOSE_PROJECT_NAME` / `SHOPWARE_DATA_ROOT` when they are empty. Sync does not read secrets from the script itself.",
"",
"Do not commit `deploy/sync.env` (add it to the shop `.gitignore`; that file is owned by `shopware-cli project create`).",
"",
"## Commands",
"",
"Run from the **shop root** (the script `cd`s to the parent of `deploy/`):",
"",
"```bash",
"bash deploy/sync-runtime.sh sync --from live --data all",
"bash deploy/sync-runtime.sh snapshot --data all",
"bash deploy/sync-runtime.sh restore --snapshot <id> --data all",
"```",
"",
"| Flag | Meaning |",
"| --- | --- |",
"| `--from <env>` | Source for `sync` (e.g. `live`). Uses `SYNC_SSH_*` / `SYNC_LIVE_*` |",
"| `--data all\\|db\\|volumes` | Default `all`. `volumes` means the bind-mount dirs, not `mysql_data` |",
"| `--volume <name>` | Single dir for `export --data volumes` (`files`, `media`, \u2026) |",
"| `--snapshot <id>` | Snapshot directory name under `SYNC_SNAPSHOT_DIR` |",
"| `--yes` | Skip the overwrite prompt |",
"",
"### Cron (consumer)",
"",
"```cron",
"15 2 * * * cd /opt/shopware/acme-staging && bash deploy/sync-runtime.sh sync --from live --data all",
"```",
"",
"Overlapping runs are blocked with `flock`.",
"",
"## After restore",
"",
"- The script tries `bin/console cache:clear` via compose `web` and **does not fail** if that errors.",
"- Optional `SYNC_REWRITE_FROM_URL` / `SYNC_REWRITE_TO_URL` rewrites `sales_channel_domain.url`.",
"- Bind-mount dirs are `chown -R 82:82` after copy so `www-data` in the Shopware image can write.",
"",
"## Safety",
"",
"- `sync` **refuses** `SYNC_ENV=live` / `prod` / `production`. Convention is pull-only onto the lower env.",
"- `restore` onto live is refused unless `SYNC_ALLOW_LIVE_RESTORE=1` (disaster recovery).",
"- Dumps contain customer data: `umask` is not forced here; keep `SYNC_SNAPSHOT_DIR` mode `700` on the host.",
"",
"## Named-volume leftover",
"",
"If a host still has `<project>_media` (etc.) from an older recipe and the bind-mount directory is missing, snapshot/export will tar that named volume once. New stacks use bind mounts only; do not add `files`/`media`/\u2026 back as named volumes in `deploy/compose.yaml`. Compose project name is `${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}`, not a hardcoded `shopware`.",
"",
"## Local `project dev` (laptop)",
"",
"Do **not** run `deploy/sync-runtime.sh` on a laptop. Local CLI compose bind-mounts the shop tree, not `SHOPWARE_DATA_ROOT`.",
"",
"```bash",
"bash deploy/sync-runtime-local.sh --from live --data all",
"# optional: --delete --dry-run",
"shopware-cli project console cache:clear",
"```",
"",
"| Live VPS (`SHOPWARE_DATA_ROOT`, default `/var/lib/shopware/data/${SHOPWARE_SHOP_ID}/live`) | Local project |",
"| --- | --- |",
"| `.../files` | `files/` |",
"| `.../media` | `public/media/` |",
"| `.../thumbnail` | `public/thumbnail/` |",
"| `.../theme` | `public/theme/` |",
"| `.../sitemap` | `public/sitemap/` |",
"",
"`--from` defaults the SSH host to that alias (`Host live` in `~/.ssh/config`). Laptop `.env` needs `SHOPWARE_SHOP_ID` (same as live). Optional `deploy/sync.env` / `SYNC_LIVE_*` / `SYNC_REMOTE_DATA_ROOT` match `sync-runtime.sh`. `--delete` is off unless passed (keeps local-only uploads). Database copy is out of scope here.",
""
],
"executable": false
},
"root/deploy/sync-runtime.sh": {
"contents": [
"#!/usr/bin/env bash",
"# VPS runtime data: mysqldump + rsync of bind-mounted host dirs over SSH. No S3.",
"#",
"# Commands: sync | snapshot | restore",
"# Direction for sync: higher env \u2192 this host (live \u2192 staging / playground / dev).",
"# Never auto-pushes into live.",
"#",
"# Shopware files live on the host under",
"# ${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/{files,media,thumbnail,theme,sitemap}",
"# (compose bind mounts). Optional SHOPWARE_DATA_ROOT is derived when unset.",
"# mysql_data / redis_data stay named volumes and are not copied here (DB is mysqldump).",
"#",
"# Copy deploy/sync.env.example \u2192 deploy/sync.env on the consumer and fill SYNC_SSH_*.",
"#",
"# bash deploy/sync-runtime.sh sync --from live --data all",
"# bash deploy/sync-runtime.sh snapshot --data all",
"# bash deploy/sync-runtime.sh restore --snapshot <id> --data all",
"#",
"# Plumbing (used over SSH on the source; stdout is the payload):",
"# bash deploy/sync-runtime.sh export --data db",
"# bash deploy/sync-runtime.sh export --data volumes --volume media",
"",
"set -euo pipefail",
"",
"usage() {",
" cat <<'EOF'",
"Usage: deploy/sync-runtime.sh <command> [options]",
"",
"Commands:",
" sync Pull DB + bind-mount dirs from a higher env onto this host",
" snapshot Write a local snapshot (mysqldump + rsync of host dirs)",
" restore Restore a local snapshot onto this host",
" export Plumbing: write a dump/tar to stdout (SSH fallback)",
"",
"Options:",
" --from <env> Source env for sync (e.g. live)",
" --data <what> all | db | volumes (default: all)",
" --volume <name> Single bind-mount dir (files|media|thumbnail|theme|sitemap)",
" --snapshot <id> Snapshot id for restore (directory name under SYNC_SNAPSHOT_DIR)",
" --yes Do not prompt",
" -h, --help",
"",
"Examples:",
" bash deploy/sync-runtime.sh sync --from live --data all",
" bash deploy/sync-runtime.sh snapshot --data all",
" bash deploy/sync-runtime.sh restore --snapshot 20260911T021500Z-live --data all",
"",
"No S3. Files stay on the VPS (rsync over SSH, or a local snapshot dir). Live",
"is never the destination of `sync`. Cron on the lower env (see",
"deploy/README.md and deploy/sync-runtime.md).",
"EOF",
"}",
"",
"log() { printf '==> %s\\n' \"$*\" >&2; }",
"die() { printf '%s\\n' \"$*\" >&2; exit 1; }",
"",
"COMMAND=\"\"",
"FROM_ENV=\"\"",
"DATA=\"all\"",
"VOLUME_KEY=\"\"",
"SNAPSHOT_ID=\"\"",
"YES=0",
"",
"while [[ $# -gt 0 ]]; do",
" case \"$1\" in",
" sync|snapshot|restore|export)",
" [[ -z \"$COMMAND\" ]] || die \"Multiple commands: $COMMAND and $1\"",
" COMMAND=\"$1\"",
" shift",
" ;;",
" --from)",
" FROM_ENV=\"${2:-}\"",
" [[ -n \"$FROM_ENV\" ]] || die \"--from requires an env name\"",
" shift 2",
" ;;",
" --data)",
" DATA=\"${2:-}\"",
" [[ -n \"$DATA\" ]] || die \"--data requires all|db|volumes\"",
" shift 2",
" ;;",
" --volume)",
" VOLUME_KEY=\"${2:-}\"",
" [[ -n \"$VOLUME_KEY\" ]] || die \"--volume requires a bind-mount dir name (files|media|thumbnail|theme|sitemap)\"",
" shift 2",
" ;;",
" --snapshot)",
" SNAPSHOT_ID=\"${2:-}\"",
" [[ -n \"$SNAPSHOT_ID\" ]] || die \"--snapshot requires an id\"",
" shift 2",
" ;;",
" --yes|-y)",
" YES=1",
" shift",
" ;;",
" -h|--help)",
" usage",
" exit 0",
" ;;",
" *)",
" die \"Unknown argument: $1\"",
" ;;",
" esac",
"done",
"",
"[[ -n \"$COMMAND\" ]] || { usage >&2; exit 1; }",
"",
"case \"$DATA\" in",
" all|db|volumes) ;;",
" *) die \"--data must be all, db, or volumes (got: $DATA)\" ;;",
"esac",
"",
"want_db() { [[ \"$DATA\" == all || \"$DATA\" == db ]]; }",
"want_volumes() { [[ \"$DATA\" == all || \"$DATA\" == volumes ]]; }",
"",
"ensure_data_root() {",
" if want_volumes && [[ -z \"${SHOPWARE_DATA_ROOT:-}\" ]]; then",
" die \"Set SHOPWARE_DATA_ROOT or SHOPWARE_SHOP_ID+SHOPWARE_DEPLOY_ENV in .env\"",
" fi",
"}",
"",
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"$(dirname \"$0\")/..\" && pwd)}\"",
"cd \"$COMPOSE_DIR\"",
"",
"CI_IMAGE=\"${IMAGE:-}\"",
"CI_IMAGE_TAG=\"${IMAGE_TAG:-}\"",
"CI_PROFILES=\"${COMPOSE_PROFILES:-}\"",
"",
"if [[ -f .env ]]; then",
" set -a",
" # shellcheck disable=SC1091",
" source .env",
" set +a",
"fi",
"if [[ -f .env.prod ]]; then",
" set -a",
" # shellcheck disable=SC1091",
" source .env.prod",
" set +a",
"fi",
"",
"IMAGE=\"${CI_IMAGE:-${IMAGE:-}}\"",
"IMAGE_TAG=\"${CI_IMAGE_TAG:-${IMAGE_TAG:-}}\"",
"COMPOSE_PROFILES=\"${CI_PROFILES:-${COMPOSE_PROFILES:-}}\"",
"export IMAGE IMAGE_TAG",
"",
"SYNC_ENV_FILE=\"${SYNC_ENV_FILE:-$COMPOSE_DIR/deploy/sync.env}\"",
"if [[ -f \"$SYNC_ENV_FILE\" ]]; then",
" set -a",
" # shellcheck disable=SC1091",
" source \"$SYNC_ENV_FILE\"",
" set +a",
"fi",
"",
"SHOPWARE_DATA_BASE=\"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}\"",
"",
"# SHOPWARE_SHOP_ID + SHOPWARE_DEPLOY_ENV (or SYNC_ENV) \u2192 project name / data root.",
"# Compose interpolates those two (+ optional SHOPWARE_DATA_BASE) itself.",
"# Scripts fill COMPOSE_PROJECT_NAME / SHOPWARE_DATA_ROOT when they are empty.",
"if [[ -z \"${SHOPWARE_DEPLOY_ENV:-}\" && -n \"${SYNC_ENV:-}\" ]]; then",
" SHOPWARE_DEPLOY_ENV=\"${SYNC_ENV}\"",
"fi",
"if [[ -z \"${SYNC_ENV:-}\" && -n \"${SHOPWARE_DEPLOY_ENV:-}\" ]]; then",
" SYNC_ENV=\"${SHOPWARE_DEPLOY_ENV}\"",
"fi",
"if [[ -z \"${COMPOSE_PROJECT_NAME:-}\" && -n \"${SHOPWARE_SHOP_ID:-}\" && -n \"${SHOPWARE_DEPLOY_ENV:-}\" ]]; then",
" COMPOSE_PROJECT_NAME=\"${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}\"",
"elif [[ -n \"${COMPOSE_PROJECT_NAME:-}\" && -n \"${SHOPWARE_SHOP_ID:-}\" && -n \"${SHOPWARE_DEPLOY_ENV:-}\" ]]; then",
" derived_project=\"${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}\"",
" if [[ \"$COMPOSE_PROJECT_NAME\" != \"$derived_project\" ]]; then",
" log \"WARNING: COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME} is set and overrides Compose name: (${derived_project}). shopware-cli project create writes COMPOSE_PROJECT_NAME=sw-shop-\u2026 into .env for local project dev. On the VPS, remove or comment out that line. This script does not delete it.\"",
" fi",
"fi",
"if [[ -z \"${SHOPWARE_DATA_ROOT:-}\" && -n \"${SHOPWARE_SHOP_ID:-}\" && -n \"${SHOPWARE_DEPLOY_ENV:-}\" ]]; then",
" SHOPWARE_DATA_ROOT=\"${SHOPWARE_DATA_BASE}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}\"",
"fi",
"if [[ -n \"${COMPOSE_PROJECT_NAME:-}\" ]]; then",
" export COMPOSE_PROJECT_NAME",
"fi",
"if [[ -n \"${SHOPWARE_DATA_ROOT:-}\" ]]; then",
" export SHOPWARE_DATA_ROOT",
"fi",
"",
"SYNC_COMPOSE_PROJECT=\"${SYNC_COMPOSE_PROJECT:-${COMPOSE_PROJECT_NAME:-}}\"",
"SYNC_VOLUMES=\"${SYNC_VOLUMES:-files,media,thumbnail,theme,sitemap}\"",
"SYNC_SNAPSHOT_DIR=\"${SYNC_SNAPSHOT_DIR:-$COMPOSE_DIR/.runtime-snapshots}\"",
"SYNC_KEEP_SNAPSHOTS=\"${SYNC_KEEP_SNAPSHOTS:-5}\"",
"SYNC_SSH_PORT=\"${SYNC_SSH_PORT:-22}\"",
"",
"if [[ \"$COMMAND\" != \"export\" && -n \"${COMPOSE_PROJECT_NAME:-}${SHOPWARE_DATA_ROOT:-}\" ]]; then",
" log \"Identity shop=${SHOPWARE_SHOP_ID:-?} env=${SHOPWARE_DEPLOY_ENV:-?} project=${COMPOSE_PROJECT_NAME:-?} data_root=${SHOPWARE_DATA_ROOT:-?}\"",
"fi",
"",
"COMPOSE=(",
" docker compose",
" --project-directory \"$COMPOSE_DIR\"",
" -f deploy/compose.yaml",
" -f deploy/compose.prod.yaml",
" -f deploy/compose.vps.yaml",
")",
"",
"PROFILE_ARGS=()",
"IFS=',' read -ra RAW_PROFILES <<< \"${COMPOSE_PROFILES:-}\"",
"for p in \"${RAW_PROFILES[@]}\"; do",
" p=\"${p// /}\"",
" if [[ -z \"$p\" ]]; then",
" continue",
" fi",
" if [[ \"$p\" == \"setup\" ]]; then",
" continue",
" fi",
" PROFILE_ARGS+=(--profile \"$p\")",
"done",
"",
"has_service() {",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" config --services 2>/dev/null | grep -qx \"$1\"",
"}",
"",
"project_name() {",
" local n",
" n=\"$(\"${COMPOSE[@]}\" config 2>/dev/null | sed -n 's/^name:[[:space:]]*//p' | head -n1 || true)\"",
" n=\"${n:-$SYNC_COMPOSE_PROJECT}\"",
" n=\"${n:-$COMPOSE_PROJECT_NAME}\"",
" [[ -n \"$n\" ]] || die \"Set COMPOSE_PROJECT_NAME in .env (e.g. \\${SHOPWARE_SHOP_ID}-\\${SHOPWARE_DEPLOY_ENV})\"",
" printf '%s\\n' \"$n\"",
"}",
"",
"volume_list() {",
" local raw=\"$SYNC_VOLUMES\" item",
" IFS=',' read -ra items <<< \"$raw\"",
" for item in \"${items[@]}\"; do",
" item=\"${item// /}\"",
" [[ -n \"$item\" ]] && printf '%s\\n' \"$item\"",
" done",
"}",
"",
"host_data_dir() {",
" printf '%s/%s\\n' \"$SHOPWARE_DATA_ROOT\" \"$1\"",
"}",
"",
"docker_volume_name() {",
" printf '%s_%s\\n' \"$(project_name)\" \"$1\"",
"}",
"",
"has_named_volume() {",
" docker volume inspect \"$(docker_volume_name \"$1\")\" >/dev/null 2>&1",
"}",
"",
"env_rank() {",
" case \"$1\" in",
" live|prod|production) echo 0 ;;",
" staging|stage) echo 1 ;;",
" playground|preview) echo 2 ;;",
" dev|local|development) echo 3 ;;",
" *) echo 10 ;;",
" esac",
"}",
"",
"is_live_env() {",
" case \"$1\" in",
" live|prod|production) return 0 ;;",
" *) return 1 ;;",
" esac",
"}",
"",
"confirm() {",
" local msg=\"$1\"",
" if [[ \"$YES\" -eq 1 || \"${SYNC_ASSUME_YES:-0}\" == 1 ]]; then",
" return 0",
" fi",
" if [[ ! -t 0 ]]; then",
" log \"$msg (non-interactive; continuing)\"",
" return 0",
" fi",
" local ans",
" read -r -p \"$msg [y/N] \" ans",
" [[ \"$ans\" == y || \"$ans\" == Y || \"$ans\" == yes ]]",
"}",
"",
"acquire_lock() {",
" local lock=\"/tmp/shopware-sync-runtime-${COMPOSE_PROJECT_NAME:-default}.lock\"",
" if command -v flock >/dev/null 2>&1; then",
" exec 9>\"$lock\"",
" if ! flock -n 9; then",
" die \"Another sync-runtime.sh is running ($lock)\"",
" fi",
" fi",
"}",
"",
"urldecode() {",
" local s=\"${1//+/ }\"",
" printf '%b' \"${s//%/\\\\x}\"",
"}",
"",
"# Sets DB_USER DB_PASS DB_HOST DB_PORT DB_NAME from DATABASE_URL (mysql://).",
"parse_database_url() {",
" local url=\"${DATABASE_URL:-}\"",
" [[ -n \"$url\" ]] || die \"DATABASE_URL is empty\"",
" url=\"${url#mysql://}\"",
" url=\"${url#mysqli://}\"",
" url=\"${url%%\\?*}\"",
" local cred hostpart",
" cred=\"${url%%@*}\"",
" hostpart=\"${url#*@}\"",
" if [[ \"$url\" == \"$cred\" ]]; then",
" die \"DATABASE_URL must look like mysql://USER:PASSWORD@HOST:3306/DATABASE\"",
" fi",
" DB_USER=\"${cred%%:*}\"",
" if [[ \"$cred\" == *:* ]]; then",
" DB_PASS=\"$(urldecode \"${cred#*:}\")\"",
" else",
" DB_PASS=\"\"",
" fi",
" DB_NAME=\"${hostpart#*/}\"",
" DB_NAME=\"${DB_NAME%%/*}\"",
" hostpart=\"${hostpart%%/*}\"",
" if [[ \"$hostpart\" == \\[* ]]; then",
" DB_HOST=\"${hostpart#\\[}\"",
" DB_HOST=\"${DB_HOST%%]*}\"",
" DB_PORT=\"${hostpart##*]:}\"",
" [[ \"$DB_PORT\" == \"$hostpart\" ]] && DB_PORT=3306",
" elif [[ \"$hostpart\" == *:* ]]; then",
" DB_HOST=\"${hostpart%%:*}\"",
" DB_PORT=\"${hostpart##*:}\"",
" else",
" DB_HOST=\"$hostpart\"",
" DB_PORT=3306",
" fi",
"}",
"",
"wait_mysql() {",
" local i",
" for i in $(seq 1 60); do",
" if \"${COMPOSE[@]}\" exec -T mysql mysqladmin ping -h 127.0.0.1 --silent >/dev/null 2>&1; then",
" return 0",
" fi",
" sleep 2",
" done",
" die \"mysql did not become ready\"",
"}",
"",
"ensure_mysql_up() {",
" if has_service mysql; then",
" log \"Starting mysql\"",
" \"${COMPOSE[@]}\" up -d --no-build mysql",
" wait_mysql",
" fi",
"}",
"",
"# Dump SQL to stdout. Logs go to stderr.",
"dump_sql() {",
" if has_service mysql; then",
" ensure_mysql_up",
" : \"${MYSQL_DATABASE:?Set MYSQL_DATABASE}\"",
" : \"${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD}\"",
" \"${COMPOSE[@]}\" exec -T \\",
" -e MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" \\",
" mysql \\",
" mysqldump \\",
" -uroot \\",
" --single-transaction \\",
" --quick \\",
" --routines \\",
" --triggers \\",
" --no-tablespaces \\",
" --default-character-set=utf8mb4 \\",
" \"$MYSQL_DATABASE\"",
" return",
" fi",
" parse_database_url",
" docker run --rm --network host \\",
" -e MYSQL_PWD=\"$DB_PASS\" \\",
" mysql:8.4 \\",
" mysqldump \\",
" -h\"$DB_HOST\" \\",
" -P\"$DB_PORT\" \\",
" -u\"$DB_USER\" \\",
" --single-transaction \\",
" --quick \\",
" --routines \\",
" --triggers \\",
" --no-tablespaces \\",
" --default-character-set=utf8mb4 \\",
" \"$DB_NAME\"",
"}",
"",
"import_sql() {",
" if has_service mysql; then",
" ensure_mysql_up",
" : \"${MYSQL_DATABASE:?Set MYSQL_DATABASE}\"",
" : \"${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD}\"",
" \"${COMPOSE[@]}\" exec -T \\",
" -e MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" \\",
" mysql \\",
" mysql \\",
" -uroot \\",
" --default-character-set=utf8mb4 \\",
" --max-allowed-packet=512M \\",
" \"$MYSQL_DATABASE\"",
" return",
" fi",
" parse_database_url",
" docker run --rm -i --network host \\",
" -e MYSQL_PWD=\"$DB_PASS\" \\",
" mysql:8.4 \\",
" mysql \\",
" -h\"$DB_HOST\" \\",
" -P\"$DB_PORT\" \\",
" -u\"$DB_USER\" \\",
" --default-character-set=utf8mb4 \\",
" --max-allowed-packet=512M \\",
" \"$DB_NAME\"",
"}",
"",
"rewrite_urls() {",
" local from=\"${SYNC_REWRITE_FROM_URL:-}\" to=\"${SYNC_REWRITE_TO_URL:-}\"",
" [[ -n \"$from\" && -n \"$to\" ]] || return 0",
" log \"Rewriting sales_channel_domain URLs: $from \u2192 $to\"",
" local sql",
" sql=$(printf \"UPDATE sales_channel_domain SET url = REPLACE(url, '%s', '%s');\\n\" \\",
" \"${from//\\'/\\'\\'}\" \"${to//\\'/\\'\\'}\")",
" printf '%s' \"$sql\" | import_sql",
"}",
"",
"# Primary: tar/rsync the bind-mounted host dir. Fallback: named Docker volume.",
"dump_volume_tar() {",
" local key=\"$1\" host vol",
" host=\"$(host_data_dir \"$key\")\"",
" if [[ -d \"$host\" ]]; then",
" tar -C \"$host\" -czf - .",
" return",
" fi",
" vol=\"$(docker_volume_name \"$key\")\"",
" if has_named_volume \"$key\"; then",
" log \"Bind-mount dir $host missing; archiving named volume $vol\"",
" docker run --rm \\",
" -v \"$vol\":/volume:ro \\",
" alpine:3.20 \\",
" tar -C /volume -czf - .",
" return",
" fi",
" die \"No bind-mount dir ($host) and no Docker volume $vol for $key\"",
"}",
"",
"rsync_local_dir() {",
" local src=\"$1\" dest=\"$2\"",
" mkdir -p \"$dest\"",
" if command -v rsync >/dev/null 2>&1; then",
" rsync -a --delete \"$src/\" \"$dest/\"",
" return",
" fi",
" find \"$dest\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
" tar -C \"$src\" -cf - . | tar -C \"$dest\" -xf -",
"}",
"",
"restore_host_tar_stdin() {",
" local dest",
" dest=\"$(host_data_dir \"$1\")\"",
" mkdir -p \"$dest\"",
" find \"$dest\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
" tar -C \"$dest\" -xzf -",
"}",
"",
"snapshot_runtime_dir() {",
" local key=\"$1\" dest=\"$2/$key\" host",
" host=\"$(host_data_dir \"$key\")\"",
" if [[ -d \"$host\" ]]; then",
" log \"Rsync $host \u2192 $dest\"",
" rsync_local_dir \"$host\" \"$dest\"",
" return",
" fi",
" if has_named_volume \"$key\"; then",
" log \"Archiving named volume $key \u2192 $2/${key}.tar.gz\"",
" dump_volume_tar \"$key\" >\"$2/${key}.tar.gz\"",
" return",
" fi",
" log \"Skip $key (no host dir $host)\"",
"}",
"",
"restore_runtime_dir() {",
" local key=\"$1\" dir=\"$2\" dest",
" dest=\"$(host_data_dir \"$key\")\"",
" mkdir -p \"$dest\"",
" if [[ -d \"$dir/$key\" ]]; then",
" log \"Rsync snapshot $key \u2192 $dest\"",
" rsync_local_dir \"$dir/$key\" \"$dest\"",
" return",
" fi",
" if [[ -f \"$dir/${key}.tar.gz\" ]]; then",
" log \"Extracting $key tar \u2192 $dest\"",
" find \"$dest\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
" tar -C \"$dest\" -xzf \"$dir/${key}.tar.gz\"",
" return",
" fi",
" log \"Skip $key (not in snapshot)\"",
"}",
"",
"stop_app() {",
" local svcs=()",
" has_service web && svcs+=(web)",
" has_service worker && svcs+=(worker)",
" has_service scheduler && svcs+=(scheduler)",
" if [[ ${#svcs[@]} -gt 0 ]]; then",
" log \"Stopping ${svcs[*]}\"",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" stop \"${svcs[@]}\" || true",
" fi",
"}",
"",
"start_app() {",
" log \"Starting web\"",
" \"${COMPOSE[@]}\" up -d --no-build --remove-orphans web",
" if [[ ${#PROFILE_ARGS[@]} -gt 0 ]]; then",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" up -d --no-build",
" fi",
"}",
"",
"chown_volumes() {",
" local key dest",
" while IFS= read -r key; do",
" dest=\"$(host_data_dir \"$key\")\"",
" mkdir -p \"$dest\"",
" log \"chown 82:82 $dest\"",
" docker run --rm \\",
" -v \"$dest\":/data \\",
" alpine:3.20 \\",
" chown -R 82:82 /data",
" done < <(volume_list)",
"}",
"",
"cache_clear() {",
" if ! has_service web; then",
" return 0",
" fi",
" log \"cache:clear\"",
" \"${COMPOSE[@]}\" exec -T web php bin/console cache:clear || log \"cache:clear failed (continuing)\"",
"}",
"",
"env_upper() {",
" printf '%s' \"$1\" | tr '[:lower:]' '[:upper:]' | tr '-' '_'",
"}",
"",
"snapshot_ids() {",
" [[ -d \"$SYNC_SNAPSHOT_DIR\" ]] || return 0",
" find \"$SYNC_SNAPSHOT_DIR\" -mindepth 1 -maxdepth 1 -type d -printf '%f\\n' | sort",
"}",
"",
"prune_snapshots() {",
" local keep=\"$SYNC_KEEP_SNAPSHOTS\"",
" [[ \"$keep\" =~ ^[0-9]+$ ]] || return 0",
" local -a ids=()",
" mapfile -t ids < <(snapshot_ids)",
" local extra=$(( ${#ids[@]} - keep ))",
" (( extra > 0 )) || return 0",
" local i",
" for ((i = 0; i < extra; i++)); do",
" log \"Pruning snapshot ${ids[i]}\"",
" rm -rf \"${SYNC_SNAPSHOT_DIR:?}/${ids[i]}\"",
" done",
"}",
"",
"from_ssh_host() {",
" local var=\"SYNC_$(env_upper \"$1\")_SSH_HOST\"",
" printf '%s\\n' \"${!var:-${SYNC_SSH_HOST:-}}\"",
"}",
"from_ssh_user() {",
" local var=\"SYNC_$(env_upper \"$1\")_SSH_USER\"",
" printf '%s\\n' \"${!var:-${SYNC_SSH_USER:-}}\"",
"}",
"from_ssh_port() {",
" local var=\"SYNC_$(env_upper \"$1\")_SSH_PORT\"",
" printf '%s\\n' \"${!var:-${SYNC_SSH_PORT:-22}}\"",
"}",
"from_ssh_identity() {",
" local var=\"SYNC_$(env_upper \"$1\")_SSH_IDENTITY\"",
" printf '%s\\n' \"${!var:-${SYNC_SSH_IDENTITY:-}}\"",
"}",
"from_ssh_path() {",
" local var=\"SYNC_$(env_upper \"$1\")_PATH\"",
" printf '%s\\n' \"${!var:-${SYNC_SSH_PATH:-}}\"",
"}",
"from_data_root() {",
" local var",
" var=\"SYNC_$(env_upper \"$1\")_DATA_ROOT\"",
" if [[ -n \"${!var:-}\" ]]; then",
" printf '%s\\n' \"${!var}\"",
" return",
" fi",
" if [[ -n \"${SYNC_REMOTE_DATA_ROOT:-}\" ]]; then",
" printf '%s\\n' \"$SYNC_REMOTE_DATA_ROOT\"",
" return",
" fi",
" if [[ -n \"${SHOPWARE_SHOP_ID:-}\" ]]; then",
" printf '%s\\n' \"${SHOPWARE_DATA_BASE}/${SHOPWARE_SHOP_ID}/$1\"",
" return",
" fi",
" printf '%s\\n' \"$SHOPWARE_DATA_BASE\"",
"}",
"",
"ssh_rsh() {",
" local a out=\"\"",
" ssh_base \"$1\"",
" for a in \"${SSH_CMD[@]}\"; do",
" out+=\"$(printf '%q ' \"$a\")\"",
" done",
" printf '%s' \"${out% }\"",
"}",
"",
"rsync_from_remote() {",
" local from=\"$1\" key=\"$2\"",
" local host user remote dest",
" host=\"$(from_ssh_host \"$from\")\"",
" user=\"$(from_ssh_user \"$from\")\"",
" remote=\"$(from_data_root \"$from\")/$key\"",
" dest=\"$(host_data_dir \"$key\")\"",
" mkdir -p \"$dest\"",
" if command -v rsync >/dev/null 2>&1; then",
" log \"Rsync ${user}@${host}:${remote}/ \u2192 ${dest}/\"",
" rsync -a --delete -e \"$(ssh_rsh \"$from\")\" \\",
" \"${user}@${host}:${remote}/\" \"${dest}/\"",
" return",
" fi",
" log \"rsync not installed; streaming tar of $key from $from\"",
" remote_export \"$from\" \"--data volumes --volume $(printf '%q' \"$key\")\" \\",
" | restore_host_tar_stdin \"$key\"",
"}",
"",
"ssh_base() {",
" local from=\"$1\" port ident known",
" port=\"$(from_ssh_port \"$from\")\"",
" ident=\"$(from_ssh_identity \"$from\")\"",
" known=\"${SYNC_SSH_KNOWN_HOSTS:-}\"",
" SSH_CMD=(ssh -o BatchMode=yes -o IdentitiesOnly=yes)",
" SSH_CMD+=(-o ControlMaster=auto -o \"ControlPath=/tmp/shopware-sync-%C\" -o ControlPersist=30)",
" SSH_CMD+=(-p \"$port\")",
" if [[ -n \"$ident\" ]]; then",
" SSH_CMD+=(-i \"$ident\")",
" fi",
" if [[ -n \"$known\" ]]; then",
" SSH_CMD+=(-o \"UserKnownHostsFile=$known\" -o StrictHostKeyChecking=yes)",
" else",
" SSH_CMD+=(-o StrictHostKeyChecking=accept-new)",
" fi",
"}",
"",
"remote_export() {",
" local from=\"$1\" args=\"$2\" host user path",
" host=\"$(from_ssh_host \"$from\")\"",
" user=\"$(from_ssh_user \"$from\")\"",
" path=\"$(from_ssh_path \"$from\")\"",
" [[ -n \"$host\" ]] || die \"Set SYNC_SSH_HOST (or SYNC_$(env_upper \"$from\")_SSH_HOST) in deploy/sync.env\"",
" [[ -n \"$user\" ]] || die \"Set SYNC_SSH_USER in deploy/sync.env\"",
" [[ -n \"$path\" ]] || die \"Set SYNC_SSH_PATH in deploy/sync.env\"",
" ssh_base \"$from\"",
" \"${SSH_CMD[@]}\" \"${user}@${host}\" \\",
" \"set -euo pipefail; cd $(printf '%q' \"$path\"); bash ./deploy/sync-runtime.sh export $args\"",
"}",
"",
"cmd_export() {",
" ensure_data_root",
" case \"$DATA\" in",
" db)",
" dump_sql",
" ;;",
" volumes)",
" [[ -n \"$VOLUME_KEY\" ]] || die \"export --data volumes requires --volume <key>\"",
" dump_volume_tar \"$VOLUME_KEY\"",
" ;;",
" all)",
" die \"export --data all is not streamed as one payload; use db or volumes\"",
" ;;",
" esac",
"}",
"",
"cmd_snapshot() {",
" acquire_lock",
" ensure_data_root",
" mkdir -p \"$SYNC_SNAPSHOT_DIR\"",
" local id ts envn",
" ts=\"$(date -u +%Y%m%dT%H%M%SZ)\"",
" envn=\"${SYNC_ENV:-local}\"",
" id=\"${SNAPSHOT_ID:-$ts-$envn}\"",
" local dir=\"$SYNC_SNAPSHOT_DIR/$id\"",
" mkdir -p \"$dir\"",
" log \"Snapshot $id \u2192 $dir\"",
" {",
" printf 'id=%s\\n' \"$id\"",
" printf 'env=%s\\n' \"$envn\"",
" printf 'created_at=%s\\n' \"$ts\"",
" printf 'data=%s\\n' \"$DATA\"",
" printf 'volumes=%s\\n' \"$SYNC_VOLUMES\"",
" printf 'data_root=%s\\n' \"$SHOPWARE_DATA_ROOT\"",
" } >\"$dir/meta.txt\"",
" if want_db; then",
" log \"Dumping database\"",
" dump_sql | gzip -c >\"$dir/db.sql.gz\"",
" fi",
" if want_volumes; then",
" local key",
" while IFS= read -r key; do",
" snapshot_runtime_dir \"$key\" \"$dir\"",
" done < <(volume_list)",
" fi",
" prune_snapshots",
" log \"Snapshot finished $id\"",
" printf '%s\\n' \"$id\"",
"}",
"",
"cmd_restore() {",
" acquire_lock",
" ensure_data_root",
" if [[ -z \"$SNAPSHOT_ID\" ]]; then",
" log \"Available snapshots in $SYNC_SNAPSHOT_DIR:\"",
" snapshot_ids || true",
" die \"restore requires --snapshot <id>\"",
" fi",
" local dir=\"$SYNC_SNAPSHOT_DIR/$SNAPSHOT_ID\"",
" [[ -d \"$dir\" ]] || die \"Snapshot not found: $dir\"",
" local this_env=\"${SYNC_ENV:-}\"",
" if [[ -n \"$this_env\" ]] && is_live_env \"$this_env\"; then",
" if [[ \"${SYNC_ALLOW_LIVE_RESTORE:-0}\" != 1 ]]; then",
" die \"Refusing restore onto live/prod (set SYNC_ALLOW_LIVE_RESTORE=1 for disaster recovery)\"",
" fi",
" fi",
" confirm \"Overwrite runtime data on ${this_env:-this host} from snapshot $SNAPSHOT_ID?\" \\",
" || die \"Cancelled\"",
" stop_app",
" if want_db; then",
" [[ -f \"$dir/db.sql.gz\" ]] || die \"Snapshot has no db.sql.gz\"",
" log \"Importing database\"",
" gzip -dc \"$dir/db.sql.gz\" | import_sql",
" rewrite_urls",
" fi",
" if want_volumes; then",
" local key",
" while IFS= read -r key; do",
" restore_runtime_dir \"$key\" \"$dir\"",
" done < <(volume_list)",
" chown_volumes",
" fi",
" start_app",
" cache_clear",
" log \"Restore finished $SNAPSHOT_ID\"",
"}",
"",
"cmd_sync() {",
" acquire_lock",
" ensure_data_root",
" [[ -n \"$FROM_ENV\" ]] || die \"sync requires --from <env> (e.g. --from live)\"",
" [[ -n \"${SYNC_ENV:-}\" ]] || die \"Set SYNC_ENV in deploy/sync.env (this host, e.g. staging)\"",
" if is_live_env \"$SYNC_ENV\"; then",
" die \"Refusing sync onto live/prod (pull on the lower env, never push into live)\"",
" fi",
" if [[ \"$FROM_ENV\" == \"$SYNC_ENV\" ]]; then",
" die \"--from ($FROM_ENV) is this host (SYNC_ENV=$SYNC_ENV)\"",
" fi",
" local from_rank to_rank",
" from_rank=\"$(env_rank \"$FROM_ENV\")\"",
" to_rank=\"$(env_rank \"$SYNC_ENV\")\"",
" if [[ \"$to_rank\" -le \"$from_rank\" ]]; then",
" die \"Refusing ${FROM_ENV} \u2192 ${SYNC_ENV} (only higher \u2192 lower, e.g. live \u2192 staging)\"",
" fi",
" # Fail closed before stopping services.",
" [[ -n \"$(from_ssh_host \"$FROM_ENV\")\" ]] || die \"Set SYNC_SSH_HOST (or SYNC_$(env_upper \"$FROM_ENV\")_SSH_HOST) in deploy/sync.env\"",
" [[ -n \"$(from_ssh_user \"$FROM_ENV\")\" ]] || die \"Set SYNC_SSH_USER in deploy/sync.env\"",
" [[ -n \"$(from_ssh_path \"$FROM_ENV\")\" ]] || die \"Set SYNC_SSH_PATH in deploy/sync.env\"",
" confirm \"Overwrite ${SYNC_ENV} runtime data with ${FROM_ENV} (${DATA})?\" || die \"Cancelled\"",
" stop_app",
" if want_db; then",
" log \"Streaming mysqldump from $FROM_ENV\"",
" remote_export \"$FROM_ENV\" \"--data db\" | import_sql",
" rewrite_urls",
" fi",
" if want_volumes; then",
" local key",
" while IFS= read -r key; do",
" log \"Syncing bind-mount dir $key from $FROM_ENV\"",
" rsync_from_remote \"$FROM_ENV\" \"$key\"",
" done < <(volume_list)",
" chown_volumes",
" fi",
" start_app",
" cache_clear",
" log \"Sync finished ${FROM_ENV} \u2192 ${SYNC_ENV} ($DATA)\"",
"}",
"",
"case \"$COMMAND\" in",
" export) cmd_export ;;",
" snapshot) cmd_snapshot ;;",
" restore) cmd_restore ;;",
" sync) cmd_sync ;;",
" *) die \"Unknown command: $COMMAND\" ;;",
"esac",
""
],
"executable": false
},
"root/deploy/sync.env.example": {
"contents": [
"# Copy to deploy/sync.env on the *consumer* (staging / playground / dev). chmod 600.",
"# Never commit filled values. Live does not need this file for `snapshot`.",
"#",
"# No S3 \u2014 mysqldump + rsync of bind-mounted host dirs over SSH (or a local snapshot).",
"#",
"# Typical: this host is staging, source is live.",
"# cp deploy/sync.env.example deploy/sync.env",
"# bash deploy/sync-runtime.sh sync --from live --data all",
"",
"# This machine's environment. Must be a *lower* env for `sync` (not live / prod).",
"SYNC_ENV=staging",
"",
"# --- SSH to the source (--from live uses these unless SYNC_LIVE_* is set) ------",
"# TODO: live VPS hostname or IP",
"SYNC_SSH_HOST=",
"SYNC_SSH_USER=",
"SYNC_SSH_PORT=22",
"# Optional private key path (mode 0600). Empty = ssh defaults / agent.",
"SYNC_SSH_IDENTITY=",
"# Optional known_hosts file (ssh-keyscan output). Empty = accept-new on first connect.",
"# SYNC_SSH_KNOWN_HOSTS=/etc/ssh/ssh_known_hosts",
"# Checkout on the source (that env's VPS_PATH). Must contain this recipe's deploy/.",
"SYNC_SSH_PATH=/opt/shopware/live",
"",
"# Optional per-source overrides (used when --from NAME matches):",
"# SYNC_LIVE_SSH_HOST=",
"# SYNC_LIVE_SSH_USER=",
"# SYNC_LIVE_SSH_PORT=22",
"# SYNC_LIVE_SSH_IDENTITY=",
"# SYNC_LIVE_PATH=/opt/shopware/live",
"# SYNC_LIVE_DATA_ROOT=/var/lib/shopware/data/acme/live",
"# SYNC_STAGING_SSH_HOST=",
"# SYNC_STAGING_PATH=/opt/shopware/staging",
"",
"# Bind-mount dirs to copy (not mysql_data / redis_data \u2014 DB is mysqldump).",
"# Host path is ${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}/<name>",
"# Optional SHOPWARE_DATA_ROOT (scripts derive that formula when unset).",
"SYNC_VOLUMES=files,media,thumbnail,theme,sitemap",
"",
"# Remote bind-mount root when it cannot be derived from SHOPWARE_SHOP_ID + --from.",
"# Default: /var/lib/shopware/data/${SHOPWARE_SHOP_ID}/<from-env>",
"# SYNC_REMOTE_DATA_ROOT=/var/lib/shopware/data/acme/live",
"",
"# Local snapshot directory (snapshot / restore). Not used as an object store.",
"SYNC_SNAPSHOT_DIR=/var/backups/shopware-runtime",
"SYNC_KEEP_SNAPSHOTS=5",
"",
"# Optional: rewrite live storefront URLs in sales_channel_domain after DB restore.",
"# SYNC_REWRITE_FROM_URL=https://www.example.com",
"# SYNC_REWRITE_TO_URL=https://staging.example.com",
"",
"# Disaster recovery only. `restore` onto live is refused unless this is 1.",
"# SYNC_ALLOW_LIVE_RESTORE=0",
""
],
"executable": false
},
"root/deploy/vps-release.sh": {
"contents": [
"#!/usr/bin/env bash",
"# Run on the VPS (or via SSH from CI) after the image has been pushed.",
"# Never builds the image and never compiles themes/assets.",
"#",
"# Required env:",
"# IMAGE registry/repo (e.g. ghcr.io/fyrst-dev/shop-name) \u2014 no real defaults",
"# IMAGE_TAG full git SHA (or a rollback tag)",
"# SHOPWARE_SHOP_ID stable shop slug (same on live + staging)",
"# SHOPWARE_DEPLOY_ENV this stack: live | staging | playground | \u2026",
"# Optional:",
"# COMPOSE_DIR shop checkout (default: repository root next to deploy/)",
"# COMPOSE_PROFILES comma-separated: redis,worker,scheduler (never include \"setup\")",
"# SMOKE_URL HTTP URL to probe after up (e.g. http://127.0.0.1:8000)",
"# COMPOSE_PROJECT_NAME scripts/docs only; unset \u2192 ${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}",
"# create writes COMPOSE_PROJECT_NAME=sw-shop-\u2026 into .env; that",
"# overrides Compose name:. On the VPS, remove or comment it out.",
"# This script does not delete it (create owns the local flow).",
"# SHOPWARE_DATA_BASE bind-mount prefix (default: /var/lib/shopware/data)",
"# SHOPWARE_DATA_ROOT bind-mount root (default: ${SHOPWARE_DATA_BASE}/<shop>/<env>)",
"# PULL_POLICY always (default, CI/VPS) | never (same-host tag-and-load / air-gap).",
"# SKIP_PULL 1/true \u2192 skip `docker compose pull`, set PULL_POLICY=never.",
"#",
"# CI-exported IMAGE / IMAGE_TAG / SKIP_PULL / PULL_POLICY always win over .env.",
"#",
"# Compose files (shop root as --project-directory):",
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
"#",
"# compose run uses --pull never (Compose v5 dropped --no-build from the run",
"# subcommand; do not pass --build). up uses --no-build.",
"",
"set -euo pipefail",
"",
"usage() {",
" cat <<'EOF'",
"Usage: bash deploy/vps-release.sh [--skip-pull]",
"",
" --skip-pull Same-host / air-gap: skip registry pull, PULL_POLICY=never",
"EOF",
"}",
"",
"SKIP_PULL=\"${SKIP_PULL:-}\"",
"for arg in \"$@\"; do",
" case \"$arg\" in",
" --skip-pull) SKIP_PULL=1 ;;",
" -h|--help)",
" usage",
" exit 0",
" ;;",
" *)",
" echo \"Unknown argument: $arg\" >&2",
" usage",
" exit 1",
" ;;",
" esac",
"done",
"",
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"$(dirname \"$0\")/..\" && pwd)}\"",
"cd \"$COMPOSE_DIR\"",
"",
"env_truthy() {",
" local v",
" v=\"$(printf '%s' \"${1:-}\" | tr '[:upper:]' '[:lower:]')\"",
" [[ \"$v\" == \"1\" || \"$v\" == \"true\" || \"$v\" == \"yes\" || \"$v\" == \"on\" ]]",
"}",
"",
"CI_IMAGE=\"${IMAGE:-}\"",
"CI_IMAGE_TAG=\"${IMAGE_TAG:-}\"",
"CI_SMOKE_URL=\"${SMOKE_URL:-}\"",
"CI_PROFILES=\"${COMPOSE_PROFILES:-}\"",
"CI_SKIP_PULL=\"${SKIP_PULL:-}\"",
"CI_PULL_POLICY=\"${PULL_POLICY:-}\"",
"",
"if [[ -f .env ]]; then",
" set -a",
" # shellcheck disable=SC1091",
" source .env",
" set +a",
"fi",
"if [[ -f .env.prod ]]; then",
" set -a",
" # shellcheck disable=SC1091",
" source .env.prod",
" set +a",
"fi",
"",
"IMAGE=\"${CI_IMAGE:-${IMAGE:-}}\"",
"IMAGE_TAG=\"${CI_IMAGE_TAG:-${IMAGE_TAG:-}}\"",
"SMOKE_URL=\"${CI_SMOKE_URL:-${SMOKE_URL:-}}\"",
"COMPOSE_PROFILES=\"${CI_PROFILES:-${COMPOSE_PROFILES:-}}\"",
"if [[ -n \"${CI_SKIP_PULL:-}\" ]]; then",
" SKIP_PULL=\"${CI_SKIP_PULL}\"",
"fi",
"if [[ -n \"${CI_PULL_POLICY:-}\" ]]; then",
" PULL_POLICY=\"${CI_PULL_POLICY}\"",
"fi",
"",
"# Compose interpolates SHOPWARE_SHOP_ID + SHOPWARE_DEPLOY_ENV from .env",
"# (optional SHOPWARE_DATA_BASE). Derive COMPOSE_PROJECT_NAME / SHOPWARE_DATA_ROOT",
"# for scripts when only shop id + deploy env are set.",
": \"${SHOPWARE_SHOP_ID:?Set SHOPWARE_SHOP_ID in .env (stable shop slug, same on live/staging/laptop)}\"",
": \"${SHOPWARE_DEPLOY_ENV:?Set SHOPWARE_DEPLOY_ENV in .env (live|staging|playground|dev)}\"",
"SHOPWARE_DATA_BASE=\"${SHOPWARE_DATA_BASE:-/var/lib/shopware/data}\"",
"derived_project=\"${SHOPWARE_SHOP_ID}-${SHOPWARE_DEPLOY_ENV}\"",
"if [[ -z \"${COMPOSE_PROJECT_NAME:-}\" ]]; then",
" COMPOSE_PROJECT_NAME=\"${derived_project}\"",
"elif [[ \"$COMPOSE_PROJECT_NAME\" != \"$derived_project\" ]]; then",
" echo \"WARNING: COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME} is set and overrides Compose name: (${derived_project}).\" >&2",
" echo \"WARNING: shopware-cli project create writes COMPOSE_PROJECT_NAME=sw-shop-\u2026 into .env for local project dev.\" >&2",
" echo \"WARNING: On the VPS, remove or comment out that line so the project name is ${derived_project}.\" >&2",
" echo \"WARNING: This script does not delete it (create owns the local flow).\" >&2",
"fi",
"if [[ -z \"${SHOPWARE_DATA_ROOT:-}\" ]]; then",
" SHOPWARE_DATA_ROOT=\"${SHOPWARE_DATA_BASE}/${SHOPWARE_SHOP_ID}/${SHOPWARE_DEPLOY_ENV}\"",
"fi",
"",
"if env_truthy \"${SKIP_PULL:-}\"; then",
" PULL_POLICY=never",
" SKIP_PULL=1",
"else",
" PULL_POLICY=\"$(printf '%s' \"${PULL_POLICY:-always}\" | tr '[:upper:]' '[:lower:]')\"",
" if [[ \"$PULL_POLICY\" == \"never\" ]]; then",
" SKIP_PULL=1",
" else",
" SKIP_PULL=0",
" fi",
"fi",
"",
": \"${IMAGE:?Set IMAGE to the registry repository}\"",
": \"${IMAGE_TAG:?Set IMAGE_TAG to the git SHA (or previous tag for rollback)}\"",
"",
"export IMAGE IMAGE_TAG SHOPWARE_SHOP_ID SHOPWARE_DEPLOY_ENV SHOPWARE_DATA_BASE PULL_POLICY SKIP_PULL",
"if [[ -n \"${COMPOSE_PROJECT_NAME:-}\" ]]; then",
" export COMPOSE_PROJECT_NAME",
"fi",
"if [[ -n \"${SHOPWARE_DATA_ROOT:-}\" ]]; then",
" export SHOPWARE_DATA_ROOT",
"fi",
"",
"touch .env.prod",
"",
"COMPOSE=(",
" docker compose",
" --project-directory \"$COMPOSE_DIR\"",
" -f deploy/compose.yaml",
" -f deploy/compose.prod.yaml",
" -f deploy/compose.vps.yaml",
")",
"",
"PROFILE_ARGS=()",
"IFS=',' read -ra RAW_PROFILES <<< \"${COMPOSE_PROFILES:-}\"",
"for p in \"${RAW_PROFILES[@]}\"; do",
" p=\"${p// /}\"",
" if [[ -z \"$p\" ]]; then",
" continue",
" fi",
" if [[ \"$p\" == \"setup\" ]]; then",
" echo \"COMPOSE_PROFILES must not include setup (the script runs that profile itself)\" >&2",
" exit 1",
" fi",
" PROFILE_ARGS+=(--profile \"$p\")",
"done",
"",
"has_service() {",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" config --services 2>/dev/null | grep -qx \"$1\"",
"}",
"",
"up_pull=()",
"if [[ \"${SKIP_PULL}\" == \"1\" ]]; then",
" up_pull=(--pull never)",
"fi",
"",
"echo \"==> Deploying ${IMAGE}:${IMAGE_TAG} from ${COMPOSE_DIR} (compose run --pull never, up --no-build)\"",
"",
"if [[ -f .deployed-tag ]]; then",
" cp .deployed-tag .previous-tag",
" echo \"==> Previous tag: $(cat .previous-tag)\"",
"fi",
"",
"if [[ \"${SKIP_PULL}\" == \"1\" ]]; then",
" echo \"==> Skipping registry pull (SKIP_PULL=1 / PULL_POLICY=${PULL_POLICY}); using images already on this host\"",
"else",
" echo \"==> Pulling images\"",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" pull",
"fi",
"",
"if has_service mysql; then",
" echo \"==> Starting mysql\"",
" \"${COMPOSE[@]}\" up -d --no-build ${up_pull[@]+\"${up_pull[@]}\"} mysql",
"fi",
"",
"if has_service redis; then",
" echo \"==> Starting redis\"",
" \"${COMPOSE[@]}\" --profile redis up -d --no-build ${up_pull[@]+\"${up_pull[@]}\"} redis",
"fi",
"",
"echo \"==> One-shot setup (shopware-deployment-helper, skip theme/assets)\"",
"\"${COMPOSE[@]}\" --profile setup run --rm --pull never setup",
"",
"echo \"==> Recreating web (no build)\"",
"\"${COMPOSE[@]}\" up -d --no-build ${up_pull[@]+\"${up_pull[@]}\"} --remove-orphans web",
"",
"if [[ ${#PROFILE_ARGS[@]} -gt 0 ]]; then",
" echo \"==> Starting extra profiles: ${COMPOSE_PROFILES}\"",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" up -d --no-build ${up_pull[@]+\"${up_pull[@]}\"}",
"fi",
"",
"printf '%s\\n' \"$IMAGE_TAG\" > .deployed-tag",
"",
"if [[ -n \"${SMOKE_URL:-}\" ]]; then",
" echo \"==> Smoke ${SMOKE_URL}\"",
" ok=0",
" for _ in $(seq 1 30); do",
" if command -v curl >/dev/null 2>&1 && curl -fsS \"$SMOKE_URL\" >/dev/null; then",
" echo \"==> Smoke OK\"",
" ok=1",
" break",
" fi",
" sleep 2",
" done",
" if [[ \"$ok\" -ne 1 ]]; then",
" echo \"Smoke check failed for ${SMOKE_URL}\" >&2",
" exit 1",
" fi",
"fi",
"",
"echo \"==> Deploy finished ${IMAGE}:${IMAGE_TAG}\"",
""
],
"executable": false
}
},
"ref": "eb919b0761534ef249ede74cd996b31671f325de"
}
}
}