Compiling ideas into code…
Wiring up APIs and UI…
Sit back for a moment while the site gets ready.
ShopifyApps

Best Practice CI/CDfor Remix Shopify Apps

Why CI/CD Matters for Shopify Apps

Running shopify app deploy from your laptop works fine when you're the sole developer on a project. The moment a second person joins, that workflow starts to collapse. There's no audit trail of who deployed what and when. There are no quality gates, nothing prevents untested or unlinted code from reaching production. And if your local node_modules drifts from the lockfile, you could ship a build that's subtly different from what was tested.

When multiple developers work on the same Shopify app, manual deploys become genuinely dangerous. One developer deploys with stale local dependencies. Another deploys from a feature branch with unfinished work. A CI/CD pipeline eliminates these risks by making deployment repeatable, testable, and auditable. Every push goes through the same linting, type checking, and testing steps before anything touches Shopify's infrastructure, and the deploy runs from a clean environment with pinned dependencies every time.


Anatomy of a Shopify App Deployment

Before building a pipeline, it helps to understand what shopify app deploy actually does under the hood. A single invocation triggers a chain of operations that must all succeed atomically:

  • Builds the Remix app (Vite compiles server and client bundles)
  • Builds Shopify Function binaries, WASM for Rust functions, or JS bundles for JavaScript functions
  • Syncs app configuration from the shopify.app.toml file
  • Registers or updates webhook subscriptions
  • Updates extension points (checkout UI extensions, admin blocks, etc.)
  • Pushes everything to Shopify's infrastructure

All of this must happen in CI the same way it does locally. The challenge is authentication, the CLI can't open a browser for OAuth in a headless environment. Shopify provides deployment tokens for this. You generate a SHOPIFY_CLI_PARTNERS_TOKEN from the Partners dashboard, store it as a GitHub secret, and the CLI authenticates non-interactively. This is the mechanism that makes automated deployment possible.


The Three-Stage Pipeline

A production-ready pipeline for Shopify Remix apps breaks into three stages: validate (catch errors early), build (compile everything), and deploy (push to Shopify). Each stage gates the next, if validation fails, the build never runs. If the build fails, nothing gets deployed.

.github/workflows/deploy.yml
name: Deploy Shopify App

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npx eslint .

      - name: Unit tests
        run: npx vitest run

  build:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Build Remix app
        run: npx remix vite:build

      - name: Build Shopify Functions
        run: |
          for dir in extensions/*/; do
            if [ -f "$dir/src/run.js" ] || [ -f "$dir/src/run.ts" ]; then
              echo "Building function in $dir"
              cd "$dir" && npx shopify app function build && cd ../..
            fi
          done

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: |
            build/
            extensions/*/dist/

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output

      - name: Deploy to Shopify
        env:
          SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }}
          SHOPIFY_API_KEY: ${{ secrets.SHOPIFY_API_KEY }}
        run: npx shopify app deploy --force

The validate stage runs TypeScript type checking, ESLint, and Vitest in sequence on a clean Node 22 environment. If any step fails, the workflow stops, there's no point building an app that doesn't pass basic quality checks. The build stage compiles the Remix app and any Shopify Functions, then uploads the output as artifacts so the deploy stage doesn't rebuild. The deploy stage only runs on pushes to main (not on PRs), downloads the pre-built artifacts, and runs shopify app deploy --force with the deployment token.


Environment Management

Most Shopify apps need at least two environments: a staging app connected to a development store for testing, and a production app serving live merchants. Staging lets you verify changes against real Shopify APIs without risking merchant data. GitHub environments make this separation clean, each gets its own secrets and optional protection rules.

Staging deploys trigger on pushes to the develop branch. Production deploys trigger on pushes to main, gated behind required reviewers. Both use the same workflow steps, only the secrets and environment context differ.

.github/workflows/deploy.yml
deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output
      - name: Deploy to staging
        env:
          SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }}
          SHOPIFY_API_KEY: ${{ secrets.STAGING_API_KEY }}
        run: npx shopify app deploy --force

deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output
      - name: Deploy to production
        env:
          SHOPIFY_CLI_PARTNERS_TOKEN: ${{ secrets.SHOPIFY_CLI_PARTNERS_TOKEN }}
          SHOPIFY_API_KEY: ${{ secrets.PRODUCTION_API_KEY }}
        run: npx shopify app deploy --force

Gate production deploys. Use GitHub's environment protection rules to require at least one reviewer approval before the production deploy job can run. This prevents accidental production deployments from merged PRs and creates an explicit approval step in your release workflow.


Generating the Deployment Token

The deployment token is what allows the Shopify CLI to authenticate in a headless CI environment. Without it, shopify app deploy tries to open a browser for OAuth, which fails in GitHub Actions. Here's how to set it up:

  1. Go to the Shopify Partners dashboard
  2. Navigate to Settings → CLI tokens (or Manage tokens)
  3. Create a new token with the appropriate scopes for your app
  4. Copy the token value, you won't see it again
  5. In your GitHub repository, go to Settings → Secrets and variables → Actions
  6. Add a new secret named SHOPIFY_CLI_PARTNERS_TOKEN with the token value

Why this token matters. The SHOPIFY_CLI_PARTNERS_TOKEN authenticates the Shopify CLI in headless (non-interactive) mode. Without it, shopify app deploy prompts for browser-based login, which fails in CI. Store it as a GitHub secret, never commit it to the repository or expose it in workflow logs.


Testing Strategies

Automated testing for Shopify apps should cover three layers: unit tests for isolated business logic, integration tests for Remix loaders and actions, and build verification to catch compilation errors. Each layer catches different classes of bugs at different costs.

Unit tests with Vitest cover pure business logic, discount calculations, validation rules, data transformations. They run in milliseconds and catch logic errors before anything touches the Shopify API.

app/utils/discount.test.js
import { describe, it, expect } from 'vitest';
import { evaluateDiscount } from '../app/utils/discount';

describe('evaluateDiscount', () => {
  it('applies percentage discount when threshold met', () => {
    const result = evaluateDiscount({
      cartTotal: 150,
      threshold: 100,
      discountType: 'percentage',
      discountValue: 10,
    });

    expect(result.discountAmount).toBe(15);
    expect(result.applied).toBe(true);
  });

  it('returns zero discount when threshold not met', () => {
    const result = evaluateDiscount({
      cartTotal: 50,
      threshold: 100,
      discountType: 'percentage',
      discountValue: 10,
    });

    expect(result.discountAmount).toBe(0);
    expect(result.applied).toBe(false);
  });
});

Integration tests verify that Remix loaders and actions work correctly with a mocked Shopify GraphQL client. These tests catch issues with query construction, response parsing, and error handling, without hitting the actual API.

app/routes/app.products.test.js
import { describe, it, expect, vi } from 'vitest';
import { loader } from '../app/routes/app.products';

describe('Products loader', () => {
  it('fetches and returns products', async () => {
    const mockAdmin = {
      graphql: vi.fn().mockResolvedValue({
        json: () => Promise.resolve({
          data: {
            products: {
              nodes: [
                { id: 'gid://shopify/Product/1', title: 'Test Product' }
              ]
            }
          }
        })
      })
    };

    const response = await loader({
      request: new Request('http://localhost/app/products'),
      params: {},
      context: { admin: mockAdmin },
    });

    const data = await response.json();
    expect(data.products).toHaveLength(1);
    expect(mockAdmin.graphql).toHaveBeenCalledOnce();
  });
});

Mock at the transport layer. Mock the Shopify GraphQL client at the transport layer, not individual API responses. This catches schema changes and query errors that response-level mocks would miss.


Environment Variables in CI

Environment variables in Shopify apps serve multiple contexts: the Remix server runtime, the Shopify CLI during deployment, and app configuration. A common pattern is to commit a .env.example file documenting every required variable, then validate them at startup with Zod to fail fast with clear error messages.

app/config.server.js
import { z } from 'zod';

const envSchema = z.object({
  SHOPIFY_API_KEY: z.string().min(1),
  SHOPIFY_API_SECRET: z.string().min(1),
  SCOPES: z.string().default('write_products,read_orders'),
  HOST: z.string().url().optional(),
});

export const config = envSchema.parse(process.env);

In your CI pipeline, these values come from GitHub secrets rather than .env files. The Zod schema catches any missing or malformed variables during the build stage, before they cause runtime errors in production.

Functions don't get env vars. Shopify Functions run in Shopify's sandboxed environment where traditional environment variables are not available. For runtime configuration, pass values through app-data metafields or extension settings in the TOML file instead.


Shopify Functions Build in CI

If your app includes Shopify Functions, discount logic, shipping rates, cart transforms, they need to be built separately from the Remix app. The build process depends on whether your functions are written in JavaScript or Rust.

For JavaScript-based functions, the Shopify CLI handles bundling. Loop through your extensions directory and build any function that has a TOML configuration:

.github/workflows/deploy.yml
- name: Build JavaScript functions
  run: |
    for dir in extensions/*/; do
      if [ -f "$dir/shopify.extension.toml" ]; then
        (cd "$dir" && npx shopify app function build)
      fi
    done

For Rust-based functions compiled to WASM, you need the Rust toolchain with the wasm32-wasip1 target installed in your CI environment:

.github/workflows/deploy.yml
- name: Setup Rust toolchain
  uses: dtolnay/rust-toolchain@stable
  with:
    targets: wasm32-wasip1

- name: Build Rust functions
  run: |
    for dir in extensions/*/; do
      if [ -f "$dir/Cargo.toml" ]; then
        (cd "$dir" && cargo build --release --target wasm32-wasip1)
      fi
    done

Caching for Faster CI

Without caching, every workflow run installs dependencies from scratch. For a Shopify app with Rust functions, that means downloading crates, compiling WASM targets, and installing npm packages, easily adding five or more minutes to every run. GitHub's cache action solves this by persisting directories between runs.

.github/workflows/deploy.yml
- uses: actions/cache@v4
  with:
    path: |
      node_modules
      ~/.cargo/registry
      ~/.cargo/git
      extensions/*/target
    key: ${{ runner.os }}-deps-${{ hashFiles('**/package-lock.json', '**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-deps-

The cache key is derived from your lockfiles. When package-lock.json or Cargo.lock changes, the cache invalidates and dependencies are reinstalled fresh. The restore-keys fallback means even a partial cache hit (same OS, different lockfile) gives you a head start over a cold install.


Pull Request Checks

Pull requests should run validation and build verification without deploying anything. The workflow above already handles this through the on: pull_request trigger and the deploy job's if condition, but the pattern is worth emphasizing.

On every PR, the pipeline runs lint, type checking, and tests. The build stage verifies the app compiles. But the deploy job is skipped because the if: github.ref == 'refs/heads/main' && github.event_name == 'push' condition isn't met. This gives reviewers confidence that the code works without any risk of accidental deployment.

If a PR breaks the build or fails tests, GitHub marks the check as failed and blocks the merge (assuming you've configured branch protection rules). This is your first line of defense against shipping broken code.


Edge Cases and Production Concerns

A few gotchas you'll encounter when deploying Shopify apps from CI:

  • The --force flag, shopify app deploy --force skips interactive confirmation prompts. Without it, the CLI hangs waiting for user input that never comes in CI.
  • TOML sync issues, Ensure shopify.app.toml is committed and correct. If the TOML references extensions or scopes that don't match the Partners dashboard, the deploy fails with cryptic errors.
  • Extension version conflicts, Deploy can fail if extension versions in your codebase don't match what Shopify expects. Keep extensions in sync by always deploying from CI rather than mixing local and automated deploys.
  • Webhook registration, shopify app deploy registers webhooks using URLs in your TOML. Ensure these URLs are correct per environment, staging should point to your staging host, production to your production host.
  • Concurrent deployments, Use GitHub's concurrency groups to prevent two deployments from running simultaneously against the same Shopify app. Concurrent deploys can leave the app in an inconsistent state.
  • Secret rotation, When rotating the SHOPIFY_CLI_PARTNERS_TOKEN, update it in all GitHub environments simultaneously. A stale token in one environment won't fail until the next deploy, which might be weeks later.
.github/workflows/deploy.yml
deploy:
  needs: build
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  runs-on: ubuntu-latest
  environment: production
  concurrency:
    group: shopify-deploy-production
    cancel-in-progress: false

Key Takeaways

  • shopify app deploy handles Remix builds, Function binaries, TOML sync, webhooks, and extensions atomically, your CI pipeline must account for all of these.
  • Use a three-stage pipeline: validate (lint, type check, test), build (Remix + Functions), deploy (with deployment token).
  • The SHOPIFY_CLI_PARTNERS_TOKEN enables headless authentication in CI. Generate it from the Partners dashboard and store it as a GitHub secret.
  • Separate staging and production with GitHub environments. Gate production behind reviewer approvals.
  • Use concurrency groups to prevent simultaneous deploys. Always use the --force flag to skip interactive prompts.
  • Cache node_modules, Cargo registries, and build targets to cut CI run time.
  • Validate environment variables at startup with Zod. Shopify Functions can't access traditional env vars, use metafields or TOML settings instead.

If your team needs a production-ready CI/CD pipeline for Shopify Remix apps, automated testing, environment management, and reliable deployments, I can help you architect and build it. Let's talk about what your development workflow actually needs.

Cross-Document View Transition API in Shopify