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.tomlfile - 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.
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.
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:
- Go to the Shopify Partners dashboard
- Navigate to Settings → CLI tokens (or Manage tokens)
- Create a new token with the appropriate scopes for your app
- Copy the token value, you won't see it again
- In your GitHub repository, go to Settings → Secrets and variables → Actions
- Add a new secret named
SHOPIFY_CLI_PARTNERS_TOKENwith 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.
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.
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.
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:
- 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:
- 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.
- 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
--forceflag,shopify app deploy --forceskips interactive confirmation prompts. Without it, the CLI hangs waiting for user input that never comes in CI. - TOML sync issues, Ensure
shopify.app.tomlis 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 deployregisters 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
concurrencygroups 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.
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 deployhandles 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_TOKENenables 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
--forceflag 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.