Files
symfony-flex-recipes-contrib/fyrst.shopware-cd.1.0.json
github-action[bot]andgithub-action[bot] da0bec75c6 Create Flex endpoint
2026-09-11 11:51:13 +00:00

2269 lines
134 KiB
JSON

{
"manifests": {
"fyrst/shopware-cd": {
"manifest": {
"copy-from-recipe": {
"root/": ""
},
"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.yaml</comment> \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>/var/lib/shopware/data/{files,media,thumbnail,theme,sitemap}</comment>",
" (<comment>SHOPWARE_DATA_ROOT</comment> in <comment>.env</comment>). On staging, copy to",
" <comment>deploy/sync.env</comment> and pull live \u2192 lower.",
"",
" Copy <comment>.env.example</comment> \u2192 <comment>.env</comment> yourself (Flex never writes <comment>.env</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).",
"",
" 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 shop-root .shopware-project.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.",
"#",
"# Generate APP_SECRET with: openssl rand -hex 32",
"# First-install admin vars are consumed by shopware-deployment-helper only on a fresh DB.",
"",
"# --- Image (Compose + CD) ----------------------------------------------------",
"# 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",
"",
"# --- 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",
"",
"# --- Optional Compose profiles on the VPS (comma-separated) ------------------",
"# COMPOSE_PROFILES=redis,worker,scheduler",
"",
"# --- VPS runtime files (bind mounts; first-class default) --------------------",
"# Host dirs mapped into the container. Per-shop if several stacks share a VPS.",
"# Compose mounts {files,media,thumbnail,theme,sitemap} from this root.",
"SHOPWARE_DATA_ROOT=/var/lib/shopware/data",
"",
"# --- 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.",
"#",
"# ---------------------------------------------------------------------------",
"# Required secrets (Settings \u2192 Secrets and variables \u2192 Actions)",
"# ---------------------------------------------------------------------------",
"# SHOPWARE_PACKAGES_TOKEN packages.shopware.com (BuildKit secret packages_token)",
"# COMPOSER_AUTH optional 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 }}",
" 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.",
"# ---------------------------------------------------------------------------",
"# SHOPWARE_PACKAGES_TOKEN packages.shopware.com (BuildKit secret packages_token)",
"# COMPOSER_AUTH optional 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}\"",
" 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",
"",
"## 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. Copy `.env.example` \u2192 `.env` and fill runtime secrets. `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_ROOT` (default `/var/lib/shopware/data`). Docker creates `{files,media,thumbnail,theme,sitemap}` on first up; `init-perm` chowns those dirs to uid 82.",
"",
"## 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`",
"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 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`",
"- `deploy/sync-runtime.sh` / `deploy/sync.env.example` / `deploy/sync-runtime.md` \u2014 live \u2192 lower runtime copy (no S3)",
"",
"shopware-cli project create owns shop-root `compose.yaml` (local). Do not point CD at that file.",
"",
"## 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** (default `/var/lib/shopware/data/{files,media,thumbnail,theme,sitemap}`). Set `SHOPWARE_DATA_ROOT` in shop-root `.env` if several shops share a VPS. `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/<shop>",
"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/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.",
"",
"## Required CI secrets (Compose path)",
"",
"See comments at the top of `.github/workflows/cd.yaml` and `.gitlab-ci.yaml`.",
"",
"Typical: `SSH_PRIVATE_KEY`, `VPS_HOST`, `VPS_USER`, `VPS_PATH`, `SSH_KNOWN_HOSTS`.",
""
],
"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",
"",
"services:",
" web:",
" image: ${IMAGE:?Set IMAGE}:${IMAGE_TAG:?Set IMAGE_TAG to the git SHA}",
" pull_policy: always",
"",
" setup:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: always",
"",
" init-perm:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: always",
"",
" worker:",
" image: ${IMAGE}:${IMAGE_TAG}",
" pull_policy: always",
"",
" scheduler:",
" image: ${IMAGE}:${IMAGE_TAG}",
" 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_ROOT:-/var/lib/shopware/data}/{files,media,thumbnail,theme,sitemap}.",
"# mysql_data / redis_data stay named volumes. Set SHOPWARE_DATA_ROOT per shop if",
"# several stacks share a Docker host.",
"#",
"# 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.",
"# TODO: set `name:` per shop if several stacks share a Docker host.",
"",
"name: shopware",
"",
"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_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/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 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_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/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_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/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_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/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_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/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/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.yaml` (from `shopware-cli project create`, 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)",
"- Build-time secrets (`SHOPWARE_PACKAGES_TOKEN`, Composer auth)",
""
],
"executable": false
},
"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_ROOT:-/var/lib/shopware/data}/<name>` |",
"",
"Not copied: `mysql_data` / `redis_data` named volumes (use `db` for SQL; Redis is ephemeral for this recipe). Do not put dumps in git.",
"",
"Set `SHOPWARE_DATA_ROOT` in shop-root `.env` when several shops share a host. If the **source** uses a non-default root, set `SYNC_REMOTE_DATA_ROOT` or `SYNC_LIVE_DATA_ROOT` in `deploy/sync.env`.",
"",
"## 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` and `SHOPWARE_DATA_ROOT` (compose interpolation). 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/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 `shopware_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`.",
""
],
"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_ROOT:-/var/lib/shopware/data}/{files,media,thumbnail,theme,sitemap}",
"# (compose bind mounts). 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 ]]; }",
"",
"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",
"",
"SYNC_COMPOSE_PROJECT=\"${SYNC_COMPOSE_PROJECT:-shopware}\"",
"SYNC_VOLUMES=\"${SYNC_VOLUMES:-files,media,thumbnail,theme,sitemap}\"",
"SHOPWARE_DATA_ROOT=\"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}\"",
"SYNC_REMOTE_DATA_ROOT=\"${SYNC_REMOTE_DATA_ROOT:-/var/lib/shopware/data}\"",
"SYNC_SNAPSHOT_DIR=\"${SYNC_SNAPSHOT_DIR:-$COMPOSE_DIR/.runtime-snapshots}\"",
"SYNC_KEEP_SNAPSHOTS=\"${SYNC_KEEP_SNAPSHOTS:-5}\"",
"SYNC_SSH_PORT=\"${SYNC_SSH_PORT:-22}\"",
"",
"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}\"",
" printf '%s\\n' \"${n:-shopware}\"",
"}",
"",
"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.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=\"SYNC_$(env_upper \"$1\")_DATA_ROOT\"",
" printf '%s\\n' \"${!var:-${SYNC_REMOTE_DATA_ROOT:-/var/lib/shopware/data}}\"",
"}",
"",
"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() {",
" 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",
" 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",
" 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",
" [[ -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": true
},
"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",
"# 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_ROOT:-/var/lib/shopware/data}/<name>",
"SYNC_VOLUMES=files,media,thumbnail,theme,sitemap",
"",
"# Remote SHOPWARE_DATA_ROOT when the source uses a non-default path.",
"# SYNC_REMOTE_DATA_ROOT=/var/lib/shopware/data",
"",
"# 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)",
"# 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)",
"#",
"# CI-exported IMAGE / IMAGE_TAG always win over .env (which often has IMAGE_TAG=latest).",
"#",
"# Compose files (shop root as --project-directory):",
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
"",
"set -euo pipefail",
"",
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"$(dirname \"$0\")/..\" && pwd)}\"",
"cd \"$COMPOSE_DIR\"",
"",
"CI_IMAGE=\"${IMAGE:-}\"",
"CI_IMAGE_TAG=\"${IMAGE_TAG:-}\"",
"CI_SMOKE_URL=\"${SMOKE_URL:-}\"",
"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:-}}\"",
"SMOKE_URL=\"${CI_SMOKE_URL:-${SMOKE_URL:-}}\"",
"COMPOSE_PROFILES=\"${CI_PROFILES:-${COMPOSE_PROFILES:-}}\"",
"",
": \"${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",
"",
"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\"",
"}",
"",
"echo \"==> Deploying ${IMAGE}:${IMAGE_TAG} from ${COMPOSE_DIR}\"",
"",
"if [[ -f .deployed-tag ]]; then",
" cp .deployed-tag .previous-tag",
" echo \"==> Previous tag: $(cat .previous-tag)\"",
"fi",
"",
"echo \"==> Pulling images\"",
"\"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" pull",
"",
"if has_service mysql; then",
" echo \"==> Starting mysql\"",
" \"${COMPOSE[@]}\" up -d --no-build mysql",
"fi",
"",
"if has_service redis; then",
" echo \"==> Starting redis\"",
" \"${COMPOSE[@]}\" --profile redis up -d --no-build redis",
"fi",
"",
"echo \"==> One-shot setup (shopware-deployment-helper, skip theme/assets)\"",
"\"${COMPOSE[@]}\" --profile setup run --rm --no-build setup",
"",
"echo \"==> Recreating web (no build)\"",
"\"${COMPOSE[@]}\" up -d --no-build --remove-orphans web",
"",
"if [[ ${#PROFILE_ARGS[@]} -gt 0 ]]; then",
" echo \"==> Starting extra profiles: ${COMPOSE_PROFILES}\"",
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" up -d --no-build",
"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": true
}
},
"ref": "ef8f53eb0e3086076a1e9868f1f16712e479d247"
}
}
}