For AI agents: the complete documentation index is available at https://mfeflow.github.io/mfeflow/llms.txt, the full documentation bundle is available at https://mfeflow.github.io/mfeflow/llms-full.txt, and this page is available as Markdown at https://mfeflow.github.io/mfeflow/guide/deploy/multi-platform.md.

One build, three platforms

This is a worked example, not the required way. It shows one build deployed to three platforms, each with its own runtime config and registry index. Take the parts that fit and drop the rest.

The setup: two platforms on Cloudflare, named cf-a and cf-b, and one Docker platform named docker-prod. A fourth target, staging, comes first.

It uses the pieces from the Cloudflare and Docker pages.

What does the layout look like?

Every platform serves the same remote files. Only the two small files differ.

                    one build (artifact "dist")
                              |
        +----------+----------+-----------+
        |          |          |           |
     staging     cf-a        cf-b     docker-prod
     Worker      Worker      Worker    nginx image
        |          |          |           |
   config+index config+index config+index config+index
   staging       cf-a        cf-b     docker-prod
        |          |          |           |
 staging.shop..  a.shop..   b.shop..    shop.example.com

Each runtime config has its own API address. It keeps environment set to prod, because the generated schema only allows three values.

File names can be anything that matches runtime-config.<name>.json.

apps/shell/public/config/runtime-config.cf-a.json
{
  "environment": "prod",
  "apiUrl": "https://api-a.example.com"
}

Make one such file for each of the four targets. The assemble script reads the file that matches the target name.

Describe the Cloudflare targets

One config file holds all three Cloudflare Workers. Each is an environment, and each uploads its own folder:

wrangler.jsonc
{
  "name": "shop",
  "compatibility_date": "2026-10-31",
  "env": {
    "staging": {
      "routes": [{ "pattern": "staging.shop.example.com", "custom_domain": true }],
      "assets": { "directory": "./site-staging", "not_found_handling": "single-page-application" },
    },
    "cf-a": {
      "routes": [{ "pattern": "a.shop.example.com", "custom_domain": true }],
      "assets": { "directory": "./site-cf-a", "not_found_handling": "single-page-application" },
    },
    "cf-b": {
      "routes": [{ "pattern": "b.shop.example.com", "custom_domain": true }],
      "assets": { "directory": "./site-cf-b", "not_found_handling": "single-page-application" },
    },
  },
}

Keep the _headers file from the Cloudflare page in apps/shell/public. The Docker platform uses docker/Dockerfile.prebuilt and the files from the Docker page.

Build once and upload it

Two workflows share one deploy file. The release workflow builds once and uploads the result as an artifact. The artifact holds every dist folder and every package.json, so a later rollback knows the versions that were built:

.github/workflows/release.yml
name: release
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v7
        with:
          name: dist
          path: |
            apps/*/dist
            apps/*/package.json

  staging:
    needs: build
    uses: ./.github/workflows/deploy-target.yml
    permissions:
      contents: read
      actions: read
      packages: write
    with:
      target: staging
      build_run: ${{ github.run_id }}

  promote:
    needs: staging
    strategy:
      fail-fast: false
      matrix:
        target: [cf-a, cf-b, docker-prod]
    uses: ./.github/workflows/deploy-target.yml
    permissions:
      contents: read
      actions: read
      packages: write
    with:
      target: ${{ matrix.target }}
      build_run: ${{ github.run_id }}

The promote job runs once per platform, in parallel. A failure on one does not stop the others. In GitHub, add a required reviewer to an environment such as docker-prod to hold that platform until someone approves.

Deploy one target

This reusable workflow does the same steps for every target. It looks up the origin and the kind, downloads the artifact of the build run you name, and deploys. Each run uses a GitHub environment named after the target, so each platform has its own secrets:

.github/workflows/deploy-target.yml
name: deploy-target
on:
  workflow_call:
    inputs:
      target:
        type: string
        required: true
      build_run:
        type: string
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.target }}
    permissions:
      contents: read
      actions: read
      packages: write
    env:
      TARGET: ${{ inputs.target }}
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile

      - id: where
        run: |
          case "$TARGET" in
            staging)     echo "origin=https://staging.shop.example.com" >> "$GITHUB_OUTPUT"; echo "kind=cloudflare" >> "$GITHUB_OUTPUT" ;;
            cf-a)        echo "origin=https://a.shop.example.com" >> "$GITHUB_OUTPUT"; echo "kind=cloudflare" >> "$GITHUB_OUTPUT" ;;
            cf-b)        echo "origin=https://b.shop.example.com" >> "$GITHUB_OUTPUT"; echo "kind=cloudflare" >> "$GITHUB_OUTPUT" ;;
            docker-prod) echo "kind=docker" >> "$GITHUB_OUTPUT" ;;
            *) echo "unknown target $TARGET" >&2; exit 1 ;;
          esac

      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: apps
          run-id: ${{ inputs.build_run }}
          github-token: ${{ github.token }}

      - if: steps.where.outputs.kind == 'cloudflare'
        env:
          ORIGIN: ${{ steps.where.outputs.origin }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          sh scripts/assemble-site.sh "$TARGET" "$ORIGIN"
          pnpm wrangler deploy --env "$TARGET"

      - if: steps.where.outputs.kind == 'docker'
        env:
          RUN: ${{ inputs.build_run }}
          REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          sh scripts/prepare-image.sh
          image="ghcr.io/$(echo "$GITHUB_REPOSITORY" | tr 'A-Z' 'a-z'):build-${RUN}"
          docker build -f docker/Dockerfile.prebuilt -t "$image" .
          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
          docker push "$image"
          echo "pushed $image"

Secrets live in each GitHub environment. The two Cloudflare platforms can hold different tokens, even different accounts.

The wrangler variable names are CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.

The last step stops at a pushed image. How it reaches your servers is yours to choose. On Kubernetes it can be one command, which is not part of this workflow and needs cluster access:

kubectl set image deployment/shop shop=ghcr.io/acme/shop:build-1234

Release flow

A merge to main builds once. It lands on staging first, and the other three platforms follow.

 merge to main
      |
      v
   build  --->  artifact "dist"
      |
      v
   staging  (check it by hand)
      |
      +-----------+-----------+
      |           |           |
      v           v           v
    cf-a        cf-b     docker-prod

The needs: staging line is the gate. If staging fails, nothing else runs.

Roll back one platform

A rollback redeploys an earlier build to one platform. The other platforms are not touched. Add a second workflow that you run by hand:

.github/workflows/rollback.yml
name: rollback
on:
  workflow_dispatch:
    inputs:
      target:
        description: Platform to roll back
        type: choice
        options: [cf-a, cf-b, docker-prod]
        required: true
      build_run:
        description: Run id of an earlier release run
        type: string
        required: true

jobs:
  rollback:
    uses: ./.github/workflows/deploy-target.yml
    permissions:
      contents: read
      actions: read
      packages: write
    with:
      target: ${{ inputs.target }}
      build_run: ${{ inputs.build_run }}

Find the run id in the address of the earlier release run. Say the Docker platform has a bad release and both Cloudflare platforms are fine.

Run the rollback for docker-prod only.

 release run 1234 (good)     release run 1250 (bad)
        |                           |
        |                    cf-a and cf-b stay on 1250
        |
        +--> rollback.yml  target=docker-prod  build_run=1234
                  |
                  v
          docker-prod serves build 1234 again

The deploy steps and scripts come from the current commit, and the build files come from run 1234. Artifacts expire after your repository's retention period, so older builds need another record, such as the image tags.

Use the mfeflow commands instead

This example uploads whole sites. If your remotes live on a CDN, mfeflow deploy, promote and rollback fit the same flow and move only a pointer. See Deploy and roll back a remote.

Next