Skip to content

Terminal

Blueprint terminal ready. Run help to see available commands.

Press Tab to complete commands and paths

Keyboard shortcuts

14 min read By Billy Noyes

Deploying Shopify themes to staging and production with GitHub Actions

Staged changes on one track pass through a release checkpoint onto a separate production track.

Last checked: September 30, 2026. The examples target Shopify CLI 4.8.3 and Node.js 24.

The difficult part of deploying a Shopify theme isn't getting files onto the store. It's making sure the version the client approved is the version customers see, without undoing a homepage someone edited that morning.

GitHub Actions gives you a repeatable way to do that. But putting shopify theme push in a workflow doesn't automatically make it a safe release process. You still have to decide what staging means, who owns the theme settings, and what counts as permission to go live.

One store, two themes

An approach that worked well for me in my agency days was one Shopify store with two themes: a live production theme and an unpublished staging theme.

A dedicated staging branch deployed automatically when work was merged into it. The client could review the unpublished theme using the store's actual products, prices and content. Production had its own release decision: merge to main, publish a tag or GitHub release, or run a workflow manually.

The useful distinction was simple: ready for review did not have to mean ready for customers.

With automatic deployment from main, that branch is intended to track production after successful deploys. With tags or manual releases, main can be ahead of the live site. Record the commit you actually deployed rather than assuming the branch tip is what's live.

Staging is a theme, not a sandbox

An unpublished theme keeps unapproved theme code off the normal storefront. It does not create another copy of the store.

Products, collections, inventory and other store-level data are shared. Changing a product in the admin changes the real product, not a staging-only version. Interactions with checkout or apps can also affect real systems; an unpublished theme is not a test-payment mode.

If you need to test destructive data changes or integrations in isolation, use a separate store with appropriately configured test services. If you mainly need to review theme work against real content, two themes on one store are often much easier to maintain.

Decide who owns the JSON

This is the part worth agreeing before writing any YAML.

Many theme editor customisations live in theme files:

  • config/settings_data.json: theme setting values and app-embed configuration.
  • templates/*.json: sections, blocks and settings for individual templates.
  • sections/*.json: section groups, such as the header and footer.

A push can replace those files with the repository's versions. If the merchant's latest changes are only on the store, they can be overwritten. --nodelete doesn't prevent that: it preserves remote-only files, but doesn't stop matching files being updated.

The approach below treats the staging branch as the source of truth for staging, and the live theme as the starting point for production settings. That is an ownership policy, not a promise that every customisation is protected.

If the client adjusts a layout on staging, pull the relevant files into a clean working tree, review the diff and commit them before the next deployment overwrites them:

shopify theme pull \
  --store your-store.myshopify.com \
  --theme STAGING_THEME_ID \
  --only "templates/index.json"

Getting that change into Git preserves it on staging. Shipping it to production is a separate, deliberate step: the production workflow below skips those JSON files.

Give each deployment a destination

Create an unpublished staging theme first. shopify theme list --store your-store.myshopify.com shows the IDs. Target staging by ID rather than relying on a name someone might change.

For unattended theme commands, Shopify documents using a password generated by the Theme Access app. Store it as a GitHub secret named SHOPIFY_CLI_THEME_TOKEN. This is a theme-access credential, not your storefront password or a personal Shopify login.

In GitHub, create staging and production environments. Set these values in each:

  • Secret: SHOPIFY_CLI_THEME_TOKEN.
  • Variable: SHOPIFY_STORE, using the store's .myshopify.com domain.
  • Staging-only variable: SHOPIFY_THEME_ID, the unpublished staging theme's numeric ID.

Both environments can point to the same store. Separate Theme Access passwords make revocation easier, but neither password is restricted to just one theme: Theme Access grants theme write access for the store. The target ID and workflow protections still matter.

Configure deployment branch rules so staging accepts staging and production accepts main for the branch-based example. Add required reviewers where your GitHub plan supports them. Simply naming an environment production does not add an approval gate.

For private repositories, environment secrets and variables require a paid GitHub plan. On Free, Pro and Team, required reviewers are limited to public repositories. Check GitHub's environment feature availability before designing the release process around approvals. If you use repository-level secrets instead, remove the unsupported environment configuration and explicitly separate the staging and production credentials.

Make staging predictable

For a theme that is already deployable from its repository root, the deployment itself is small:

shopify theme check --fail-level error
shopify theme push --theme "$STAGING_THEME_ID" --json

The workflow supplies SHOPIFY_FLAG_STORE and SHOPIFY_CLI_THEME_TOKEN. The push updates the specified theme; it doesn't publish it. The JSON response includes preview and editor URLs.

A push trigger on staging runs after a merge, but also after a direct push. Protect the branch if pull requests are meant to be the only route in. “Deploy on merge” is a team rule backed by branch protection, not a special property of the event.

Without ignore rules, a push synchronises the local theme files and can delete remote-only files. Review your .shopifyignore and any shopify.theme.toml defaults too; they can change what is uploaded.

Prepare the release before making it live

You can update the existing production theme with shopify theme push --live --allow-live. The last flag permits writing to the live theme in non-interactive use; it is not a safety check or an approval process.

Another option is to prepare a separate release theme:

  1. Find and record the current live theme ID.
  2. Duplicate that theme, preserving its current configuration.
  3. Push the release's code into the unpublished copy.
  4. Publish the copy only after the push succeeds.

This avoids uploading a sequence of changed files into the theme shoppers are currently using. Publishing changes which theme is live; it is not a transaction covering app state, store data or every browser already viewing the site.

To preserve the copied layout configuration, the production push uses:

shopify theme push \
  --theme "$NEW_ID" \
  --nodelete \
  --ignore "config/settings_data.json" \
  --ignore "templates/*.json" \
  --ignore "sections/*.json"

In CLI 4.8.3, templates/*.json also matches nested template paths, including customer and metaobject templates.

This intentionally creates a release from two inputs: code from the selected Git commit, and configuration copied from the live theme. It is not necessarily byte-for-byte the staging theme the client reviewed. Check that combination, especially when a release changes section settings or layout structure.

A complete starting workflow

Save this as .github/workflows/deploy-theme.yml. It automatically updates staging on a push to staging, and prepares and publishes a production copy on a push to main.

It assumes a buildless theme at the repository root, no conflicting CLI defaults, and the GitHub environment values described above. The copy-and-publish strategy is an extension to the two-theme agency setup: it creates an additional theme for each production release.

The example pins CLI 4.8.3 instead of a floating major version. Node.js 24 meets that version's minimum requirement of Node.js 22.12. For stricter reproducibility, also pin GitHub actions to reviewed full commit SHAs rather than moving major tags.

Show the complete staging and production workflow
name: Deploy Shopify theme

on:
  push:
    branches: [staging, main]

permissions:
  contents: read

concurrency:
  group: shopify-theme-${{ github.ref_name == 'main' && 'production' || 'staging' }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
    defaults:
      run:
        shell: bash
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - name: Install Shopify CLI
        run: npm install -g @shopify/cli@4.8.3
      - name: Check theme
        run: shopify theme check --fail-level error

      - name: Deploy staging
        if: github.ref == 'refs/heads/staging'
        env:
          SHOPIFY_FLAG_STORE: ${{ vars.SHOPIFY_STORE }}
          SHOPIFY_CLI_THEME_TOKEN: ${{ secrets.SHOPIFY_CLI_THEME_TOKEN }}
          STAGING_THEME_ID: ${{ vars.SHOPIFY_THEME_ID }}
        run: |
          set -euo pipefail
          : "${SHOPIFY_FLAG_STORE:?Missing store}"
          : "${SHOPIFY_CLI_THEME_TOKEN:?Missing Theme Access password}"
          : "${STAGING_THEME_ID:?Missing staging theme ID}"
          if [[ ! "$STAGING_THEME_ID" =~ ^[0-9]+$ ]]; then
            echo "Staging theme ID must be numeric." >&2
            exit 1
          fi
          shopify theme push --theme "$STAGING_THEME_ID" --json

      - name: Deploy production
        if: github.ref == 'refs/heads/main'
        env:
          SHOPIFY_FLAG_STORE: ${{ vars.SHOPIFY_STORE }}
          SHOPIFY_CLI_THEME_TOKEN: ${{ secrets.SHOPIFY_CLI_THEME_TOKEN }}
        run: |
          set -euo pipefail
          : "${SHOPIFY_FLAG_STORE:?Missing store}"
          : "${SHOPIFY_CLI_THEME_TOKEN:?Missing Theme Access password}"

          LIVE_ID=$(shopify theme list --role live --json | jq -er 'select(length == 1) | .[0].id')
          if [[ ! "$LIVE_ID" =~ ^[0-9]+$ ]]; then
            echo "Live theme ID must be numeric." >&2
            exit 1
          fi
          NAME="Release $(date -u +%Y%m%d-%H%M%S) ${GITHUB_SHA::7}"
          NEW_ID=$(shopify theme duplicate --theme "$LIVE_ID" --name "$NAME" --force --json | jq -er '.theme.id // empty')
          if [[ ! "$NEW_ID" =~ ^[0-9]+$ || "$NEW_ID" == "$LIVE_ID" ]]; then
            echo "Duplicate did not return a distinct numeric theme ID." >&2
            exit 1
          fi

          printf 'Previous live theme: %s\nCandidate theme: %s\nCommit: %s\n' "$LIVE_ID" "$NEW_ID" "$GITHUB_SHA"
          printf 'Previous live theme: `%s`\nCandidate theme: `%s`\nCommit: `%s`\n' "$LIVE_ID" "$NEW_ID" "$GITHUB_SHA" >> "$GITHUB_STEP_SUMMARY"

          shopify theme push \
            --theme "$NEW_ID" \
            --nodelete \
            --ignore "config/settings_data.json" \
            --ignore "templates/*.json" \
            --ignore "sections/*.json"

          # A different live theme means the release was prepared against an outdated baseline.
          CURRENT_LIVE_ID=$(shopify theme list --role live --json | jq -er 'select(length == 1) | .[0].id')
          if [[ "$CURRENT_LIVE_ID" != "$LIVE_ID" ]]; then
            echo "The live theme changed during deployment; leaving the candidate unpublished." >&2
            exit 1
          fi

          shopify theme publish --theme "$NEW_ID" --force

The shell stops on command and pipeline failures, rejects missing or invalid theme IDs, and records both the candidate and previous live theme. The final check refuses to publish if another process has changed the live theme ID in the meantime. It does not detect edits made within the same theme or provide a lock against Shopify admin activity.

This example publishes automatically after a successful upload. If someone must inspect the candidate first, add a separate review stage before publication; an environment approval before this job starts is not a review of a candidate that does not yet exist.

--force skips the duplicate/publish confirmations. The production gate, if you want one, belongs in GitHub's environment protections. Theme Check catches static theme issues, not visual regressions or every runtime problem; a successful check is not a substitute for storefront review.

Choose what “release” means

Automatic production deployment works well when the team treats merging as permission to ship. It is less useful when a client wants the changes ready on Tuesday but live on Friday.

Release on merge. Keep the workflow as written. Every push to main attempts a release, subject to any configured environment protections.

Release from a tag. Use a separate production workflow with:

on:
  push:
    tags: ['v*']

Or publish a GitHub release:

on:
  release:
    types: [published]

Choose one of those triggers for the same release process. Listening to both can deploy twice when a tag is pushed and its release is published. The published event includes pre-releases; for stable releases only, add a job condition:

if: github.event_name == 'release' && github.event.release.prerelease == false

A tag name is not proof that its commit went through review. A v* environment rule restricts names, not commit ancestry. Restrict who can create release tags and verify that the tagged commit belongs to the approved release history. For tag and release events, the default checkout uses the triggering ref; don't override it with ref: main and accidentally deploy something else.

Tags or releases created with a workflow's default GITHUB_TOKEN generally do not trigger another workflow. Use an appropriately scoped GitHub App or personal access token if you need that event chain, or invoke the deployment explicitly rather than relying on a second event.

Release manually. Use:

on:
  workflow_dispatch:

The workflow must exist on the default branch for manual dispatch. Since a run can target another branch or tag, restrict a main-only production job with if: github.ref == 'refs/heads/main', alongside environment branch restrictions.

When extracting production into its own workflow, retain checkout, Node setup, CLI installation and Theme Check. Remove the staging step, set environment: production, use the selected trigger, and replace the main-only condition on the production step when deploying tags or releases. A run button or release event doesn't bypass a condition that still says “main only”.

One production writer at a time

All workflows deploying to this production target in the repository should use the same concurrency group:

concurrency:
  group: shopify-theme-production
  cancel-in-progress: false

Don't put the tag name in that group: different tags could then deploy simultaneously. For several stores, use a distinct group per target.

This prevents overlap within the repository; it doesn't coordinate someone deploying from their laptop or another repository. With the default queue behaviour, a newer waiting run replaces an older waiting run. It is not a guarantee that every commit deploys, or that an old rerun can never overwrite a newer release. cancel-in-progress: false avoids automatically interrupting a running upload, but manual cancellation remains possible.

Preserve settings without freezing the theme forever

Ignoring JSON is a boundary, not a migration strategy. New templates, approved layout changes and section-group updates are skipped too. Apply those deliberately to the unpublished candidate before publishing it, after checking them against the live configuration.

For example, once its referenced sections are available, an intentional new template can be uploaded to the candidate:

shopify theme push \
  --store "$SHOPIFY_FLAG_STORE" \
  --theme "$NEW_ID" \
  --only "templates/page.landing.json" \
  --nodelete

Do this before the workflow's publish command. A persistent ignore rule in .shopifyignore can still exclude the file, so check that too. Don't put release-specific files into the live theme early just to make a later duplicate pick them up.

Saved settings also depend on the code that reads them. Renaming or removing section settings can break a layout even if its JSON was preserved. Review settings-schema changes, translations and app integrations rather than promising that these three ignore rules protect everything.

Ask the merchant to avoid editing the live theme during the copy-to-publish window, or reconcile those edits before publication. Changes saved after the copy remain on the old theme; they aren't automatically transferred to the new one.

Keep a rollback, not just a pile of copies

The previously live theme becomes unpublished. Its recorded ID is your starting point for rollback:

shopify theme publish \
  --store your-store.myshopify.com \
  --theme PREVIOUS_THEME_ID \
  --force

That changes the theme, not orders, product data, app configuration or external systems. Review any configuration changes made since the release before switching back.

Keep successful release records linking commit, candidate theme ID and previous live theme ID. Don't delete backups simply because their names sort oldest first: a failed deployment can leave a newer, unpublished theme that was never a working release.

Theme slots are finite: Shopify currently lists 20 on Basic, Grow and Advanced, and 100 on Plus. Leave room for a new candidate. Review and delete known obsolete copies using release records, excluding the live theme, staging and retained rollback themes. The workflow deliberately does not automate deletion.

If your theme needs a build

Build before Theme Check and deployment, in the same job, so the checked files are the files being uploaded:

- run: npm ci
- run: npm run build

Those commands assume an npm lockfile and a build script. Use your project's equivalent for pnpm or another tool. If the result is a complete theme in dist/, pass --path dist to Theme Check and to the theme push and publish commands. A folder containing only compiled JavaScript and CSS is not a complete Shopify theme.

Pick who controls deployment

Shopify's GitHub integration is a different route: it synchronises a connected branch and theme, and sends theme edits back to GitHub. It can coexist with CLI tooling, but choose which mechanism controls releases for each theme. Otherwise branch sync and an Actions deploy can become competing writers.

A connected branch needs deployable theme files in Shopify's expected structure. A separate compiled branch is one option for a build pipeline, not a requirement: keeping generated theme files alongside source is another documented approach.

Keep Theme Access credentials out of the repository and restrict who can change deployment workflows. Treat code running with deployment secrets as trusted code: don't expose those credentials to untrusted pull-request builds. Revoke unused passwords, and avoid verbose logs that may reveal sensitive data. GitHub's masking is a safeguard, not a reason to print secrets.

The best part of this setup isn't the YAML. It's being able to answer three questions without guessing: what is the client reviewing, what is live, and which release can we return to?

Get those boundaries right, and deployment becomes a repeatable part of the work rather than the nervous bit at the end.

References

Search suggestions are unavailable. Try again.

Loading...