20 min read

How We Run Shopify Themes in GitHub: Branches, Naming, and Safe Theme Updates

A misty Cascade river whose side channels split from the main channel and rejoin it, with an illustrated card showing a feature branch merging into main.

Why we put every Shopify theme in Git

Every theme we touch lives in a GitHub repository, with a main branch for the live store, a staging branch for review, and short-lived branches for each piece of work. That setup is what lets us customize a theme heavily and still take the theme developer's updates later without losing anything.

Without version control, a Shopify theme is a pile of files that anyone with admin access can change at any time. Someone tweaks the announcement bar, a developer edits a snippet, an app injects code, and nobody can say later which change broke the cart. Git gives every change a name, a date, and an author, and it gives us a clean way to undo one.

This post is our playbook. If you're a developer or agency, it's the branch model, naming rules, and update process we use, and you're welcome to borrow any of it. If you're a merchant thinking about working with us, skip to the last section for a plain-language version of what to expect.

How Shopify's GitHub integration works

A still Cascade alpine lake at dawn mirroring the evergreen ridge above it, with thin mist on the water.

You connect one branch of a repository to one theme in the store, and from then on the branch and the theme stay in sync in both directions. Push a commit and the theme updates. Save something in the admin and Shopify commits it back to the branch.

Setup is quick. After installing the Shopify GitHub app, go to Online Store > Themes, choose Add theme > Connect from GitHub, and pick the account, repository, and branch. The theme card then shows the repo, the branch, and the time of the last commit.

A few behaviors shape everything else in this post:

  1. There's no pause button.
    The only way to stop the sync is to disconnect the branch, and that's permanent (more on that below). Any save in the theme editor, the code editor, or by a theme app lands as a commit on the connected branch, authored by the Shopify bot. The commit body names the shop and the last person who saved. Saves within about 10 seconds of each other are batched into one commit.
  2. The branch has to look like a theme.
    Shopify only reads the standard theme folders (assets, blocks, config, layout, locales, sections, snippets, templates).
  3. Disconnecting is permanent.
    A branch can't be reconnected to a theme if it gets disconnected. Reconnecting creates a brand new theme. Publishing a connected theme, on the other hand, keeps the connection, which is how main ends up driving the live store.
  4. The code editor wins quietly.
    Shopify warns you about conflicts in the theme editor, but the admin code editor overwrites the GitHub version with no alert. If a theme ever drifts from its branch, the theme card's Actions > Reset to last commit pulls the branch back down, and View logs shows recent sync events.

What belongs to us and what belongs to the merchant

The most important idea in the whole workflow is that a theme has two kinds of files. Code files (Liquid, CSS, JavaScript, and section schema) are ours. Settings files (JSON) are the merchant's.

When a merchant changes a color, rearranges a homepage, or edits a product template in the theme editor, Shopify writes that to JSON: config/settings_data.json, the templates/*.json files, and section group files like sections/header-group.json. Editing default theme content writes to the locales files too. If the store customizes pages by market, Shopify also writes contextual template files like templates/index.context.ca.json, plus config/markets.json. Those files change on the live theme every week, often every day, and none of those changes come from us.

So our rule is simple. On the live branch, JSON is content, not code. We never let a merge from staging or dev overwrite the merchant's current JSON, and we always pull the live branch's latest commits down before promoting anything up. The next section shows how.

The branch model: main, staging, and dev

We run three long-lived branches, each connected to its own theme, plus short-lived branches for individual pieces of work. Code moves up toward the live store through pull requests. The merchant's edits move back down by merging main into the branches below it.

Branch graph: a feature/size-chart branch starts from dev and merges back by PR, dev merges into staging, a theme editor save on main (marked with the Shopify bag) merges down into staging and dev, the client approves on staging, and one highlighted PR takes staging into main.

The Shopify bag marks a commit the Shopify bot makes when someone saves in the theme editor.

BranchConnected themeWho changes itWhat it's for
mainThe published, live themeUs (by pull request only) and the merchant (theme editor)The single source of truth for what customers see
stagingAn unpublished staging themeUsThe release candidate the client reviews and approves
devAn unpublished dev themeUsWhere finished features come together and get tested against each other
feature/*, fix/*Optional unpublished theme, or a local Shopify CLI dev themeOne developerOne task, one branch, deleted after merge

Promoting work without overwriting the merchant

The failure we're designing around is easy to fall into. You branch staging off main in March, the merchant rebuilds their homepage in the theme editor in April, and in May you merge staging into main. Git happily replaces April's homepage with March's.

The fix is to always bring main down before taking anything up:

  1. Pull the live branch so you have every commit the Shopify bot made.
  2. Merge main into staging (and into dev, so it never drifts far).
  3. Resolve any conflicts in JSON files in favor of main, unless the change is one you meant to ship.
  4. Push staging, let the staging theme update, and have the client review it.
  5. Open a pull request from staging into main, and merge it once it's approved.
git checkout main && git pull origin main
git checkout staging && git pull origin staging
git merge main
# fix conflicts, keeping main's version of merchant-owned JSON
git push origin staging
# then open a PR: staging -> main

When we review that pull request, we look at every JSON file in the diff. If a file only changed because someone clicked around the staging theme editor, we put main's version back before merging:

git checkout main -- config/settings_data.json

Keep the gap between the merge-down and the final merge short. If the merchant saves the same file on the live theme in between, GitHub will flag a conflict on the pull request, which is annoying but far better than a silent overwrite.

Hotfixes and campaigns

An urgent fix doesn't wait for the full cycle. We branch fix/ off main, open a pull request straight back into main, and then merge main down into staging and dev so the fix doesn't get lost on the next release.

For a sale or seasonal campaign, Shopify's own guidance is to build it on a non-main branch, publish that theme for the event, and republish the main theme afterward. The catch is that any theme editor changes the merchant makes while the campaign theme is live are saved to the campaign branch, not to main. We merge anything worth keeping back into main when the campaign ends.

Shipping a new template without rebuilding it on live

The layout of a new template travels with the code, but the store data it points to doesn't. So we build new templates in the same store as the live theme, and the only step left after the merge is assigning the template.

A JSON template is a data file. It lists the sections on the page, their order, their blocks, and every setting the developer chose. When a developer builds templates/product.feature.json in the theme editor, all of that is saved in that one file, and it merges into main like any other file.

What the file holds is a set of references to store data, not the data itself:

Travels with the template fileLives in the store, not the theme
Which sections are on the page, and in what orderImages and videos uploaded to Files
Blocks inside each sectionMetaobject definitions and entries
Text, colors, spacing, and layout choicesMetafield definitions and values
Which image, product, collection, page, or menu each setting points toThe products, collections, pages, and menus themselves
Market overrides, saved in separate .context. files beside itWhich products or pages use the template

Reference diagram: the template file holds sections, blocks, layout choices, and which image, product, page, or menu it uses; that last row points to Files, metaobjects, metafields, and products, collections, pages, and menus, which all live in the store.

Shopify's setting docs spell out what happens when a reference doesn't resolve. An image setting pulls from the store's Files and comes back empty if the file isn't there. Product, page, and metaobject pickers come back blank if the item doesn't exist. Nothing errors. The section just renders without its content.

Build it on staging, in the live store

Because the staging and dev themes sit in the same store as the live theme, every image, metaobject, and product the developer picks already exists where it's going. Here's how a new template ships:

  1. The developer creates the template on the staging or dev theme in the theme editor and builds out the page with real content.
  2. The Shopify bot commits the new file to that branch. The developer pulls it down so the commit is in their local history.
  3. We merge main into staging as usual, then open a pull request from staging into main. A brand new template file almost never conflicts.
  4. Once it merges, the template is on the live theme, laid out and filled in exactly as it was on staging.
  5. We assign it to the products or pages that should use it in the Shopify admin, then spot check those pages on the live store.

Assignment is the one step that can't be done ahead of time. It's a setting on each product or page, not part of the theme, and the admin only offers templates the published theme has.

When it was built in a different store

If the template was built in a separate development store, its references point at that store's data. Before or right after the merge, someone has to recreate the metaobject definitions and entries, metafields, menus, and uploaded files on the live store, then open the template in the live theme editor and re-pick anything that came up empty. That's why we keep development inside the client's own store whenever we can.

Changing a template that already exists

Editing an existing template like index.json or product.json is riskier than adding a new one, because the merchant may have changed that same file on live since you branched. Before you touch it, pull main and merge it into your branch, so you're editing the current live version. Then review that file's diff line by line in the pull request.

For a big change to a page the merchant edits often, like a homepage rebuild, we agree on a short pause in theme editor changes to that page while the release goes out. It's a small ask, and it means nobody's afternoon of edits gets overwritten.

Repo setup and naming conventions

Naming feels like housekeeping until you have a dozen stores, 20 themes in a library, and a developer asking which one is live. Shopify caps a store at 20 themes, so every name has to say what the theme is and where it came from.

ThingOur conventionExample
RepositoryClient or brand name, underscore, platformacmeoutdoor_shopify
Long-lived branchesmain, staging, devstaging
Work branchesType, slash, short description in kebab casefeature/size-chart, fix/cart-drawer-overflow
Vendor branchvendor/ plus the theme namevendor/horizon
Update branchesupdate/ plus the theme and versionupdate/horizon-3.2.0
Campaign branchescampaign/ plus the event and yearcampaign/bfcm-2026
Themes in the adminEnvironment in brackets, theme, branch[STAGING] Horizon · staging
Custom filesA fixed prefix on everything we addsections/custom-size-chart.liquid
Release tagsrelease/ plus the daterelease/2026-10-28

Commits and pull requests

Commit messages start with a type, so the history reads like a changelog: feat: add size chart section, fix: stop cart drawer overflow on mobile, vendor: Horizon 3.2.0 (untouched). Commits from the Shopify bot stay exactly as Shopify writes them, which makes it easy to tell merchant edits from ours at a glance.

Every pull request includes the preview link for the connected theme, a short list of what to test, and a note on any JSON files it touches. Nothing reaches main without one. We protect main with a GitHub repository rule so it can't be force pushed or deleted, and so our changes need a pull request. The rule needs one exception. Theme editor saves reach main as direct commits from the Shopify bot, not pull requests, so we add the Shopify GitHub app to the rule's bypass list. Without it, GitHub rejects the merchant's saves. Bypass lists only work on repos owned by an organization, and protecting a private repo requires a paid GitHub plan, which is one more reason client repos belong in an organization. GitHub's branch protection docs cover the setup.

The README is the map

Every repo's README answers the questions a new developer, or future us, will ask on day one. The most important part is the remotes, copied straight from git remote -v, so nobody has to guess where code comes from and where it goes.

# Acme Outdoor, Shopify theme

Store: acme-outdoor.myshopify.com
Base theme: Horizon 3.2.0 (installed 2026-03-04)

## Branches and themes
- main     -> [LIVE] Horizon · main (published)
- staging  -> [STAGING] Horizon · staging
- dev      -> [DEV] Horizon · dev

## Remotes (git remote -v)
origin    git@github.com:acme-outdoor/acmeoutdoor_shopify.git (fetch)
origin    git@github.com:acme-outdoor/acmeoutdoor_shopify.git (push)
horizon   https://github.com/Shopify/horizon.git (fetch)
horizon   https://github.com/Shopify/horizon.git (push)

## Core file edits
See docs/changes.md

Local development with Shopify CLI

For day-to-day work on a feature branch we usually skip connecting a theme at all. Shopify CLI's shopify theme dev gives each developer a private development theme with live reload. When we push files to a shared theme from the command line, we use the --ignore flag to skip config/settings_data.json and the JSON templates, so a push never clobbers what someone set up in the theme editor.

Keeping customizations safe on a theme like Horizon

A small cedar cabin raised on stilts above a full creek in morning mist.

The whole trick to surviving theme updates is keeping the vendor's code and our code in separate places, so an update replaces their files and leaves ours alone. When the two are tangled together, every update turns into a line-by-line hunt for what we changed.

What Shopify's updater does on its own

Shopify's built-in updater is better than it used to be, and it's worth knowing exactly what it does. According to Shopify's help docs on updating themes:

  • The update arrives as a new draft theme named "Updated copy of" your theme. Your live theme isn't touched until you publish the copy.
  • Theme editor customizations (settings, section and block layout, app embeds, and edited content) are copied into the new version.
  • Code edits are carried over only if they don't conflict with the update. The theme card tells you whether they were included. If they weren't, you copy them over by hand.
  • Changes to schema limits and constraints can make an update fail, and custom CSS can break if the selectors it targets change.

That's fine for a lightly edited store. For a store we've customized, we'd rather do the update in Git, where we can see every change as a diff, test it on staging, and keep the live theme connected to its branch. The updater's copy is a brand new theme, so it isn't tied to your repo until you bring it in.

Rules for custom code

These follow the same principles Shopify's Growth Services team lays out in its guide to customizing Horizon while keeping it upgradable:

  1. Start from an untouched install.
    The first commit in the repo is the vendor theme exactly as shipped, and the README records its version.
  2. Prefix everything we add.
    New sections, snippets, blocks, and assets all carry the same prefix, like sections/custom-size-chart.liquid or blocks/custom-badge.liquid. Horizon is built around theme blocks, so most new features can be a custom block the merchant drops in, with no core file touched.
  3. Copy, don't edit.
    When we need to change a core section, we copy it to a prefixed file, change the copy, and point the template at it. The original stays in place so updates can still replace it.
  4. One hook in the layout.
    A single {% render 'custom-globals' %} line in layout/theme.liquid loads our CSS, JavaScript, and global snippets. That's one known line to restore after an update instead of dozens.
  5. Never edit vendor CSS or JS.
    Overrides go in assets/custom.css and assets/custom.js, scoped to our own sections where we can.
  6. Use settings and metafields instead of hard-coding.
    If the merchant might want to change it, it belongs in a setting, a metafield, or a block, not in Liquid.
  7. Log every core edit.
    When a core edit truly can't be avoided, we wrap it in a comment marker and list it in docs/changes.md. That file is the checklist for the next update.
{% comment %} CUSTOM EDIT START: see docs/changes.md {% endcomment %}
  ...
{% comment %} CUSTOM EDIT END {% endcomment %}

How we take a vendor update

We keep the vendor's code on its own branch, vendor/horizon, which only ever holds untouched releases. Shopify publishes Horizon's source in a public GitHub repo, and its README suggests adding it as a remote to stay current. One caution from that same README: its main branch "may include code for features not yet released," so we update to the version merchants actually get in the Theme Store rather than the tip of main. The repo has also lagged behind the Theme Store before. In 2026 it sat a couple of versions back for months and was briefly archived, which Shopify later said was a mistake. Pulling each release from the Theme Store avoids both problems.

Branch graph: vendor/horizon starts at the untouched first commit on main, an update branch comes off dev, Horizon 3.2.0 lands on the vendor branch and merges into the update branch at the highlighted step where conflicts get resolved, then the update merges into dev for testing.

The cleanest way to get that exact version is to add a fresh, unedited copy of the new release to the store as an unpublished theme, then pull it into the vendor branch:

# 1. Record the untouched new release on the vendor branch
git switch vendor/horizon
shopify theme pull --theme <fresh-theme-id> --store acme-outdoor.myshopify.com
git add -A
git commit -m "vendor: Horizon 3.2.0 (untouched)"

# 2. Merge it into an update branch made from dev
git switch dev
git merge main
git switch -c update/horizon-3.2.0
git merge vendor/horizon

Because our work lives in prefixed files, most of the merge is clean. Conflicts show up only in the core files listed in docs/changes.md, which is exactly where we expect them. For merchant-owned JSON (config/settings_data.json, the templates, and any market context files), we keep the store's version and only add new settings or templates the update introduces.

Then the update goes through the normal path. It's tested on the dev theme, reviewed on staging against a checklist of the store's key pages and custom features, and merged into main by pull request. Shopify's Growth Services guide notes the Theme Updater can reset template assignments, so we also check that each JSON template still points at our custom sections.

The one requirement for all of this is that the vendor branch and main share history. That's why the first commit matters. If you're adopting this on a store that was never in Git, the order matters. Start the repo with an untouched copy of the same theme version the store runs, and make that the first commit on both vendor/horizon and main. Then pull the live theme as it is today and commit it on top of main. Now Git has a common starting point, and that second commit shows exactly what the store changed.

When the client owns the repo

A fire lookout on a Cascade ridge above low clouds at golden hour, with an illustrated pull request card floating beside it.

Some clients already have a GitHub organization, an in-house developer, or another agency, and the theme repo belongs to them. That's a good thing for the client, and it changes one rule for us: their repo is the source of truth, and our work reaches the live store only when it's merged into their branch.

There are two ways we set this up, depending on how much access the client wants to give.

Direct accessOur own downstream copy
How it worksThe client adds us to their GitHub organization with write accessWe keep a fork or separate repo and pull from theirs
originTheir repoOur repo
upstreamNot neededTheir repo
How our work landsWe push branches and open pull requests in their repoWe open pull requests from our repo into theirs
Who connects ShopifyEither of us, from their repoThem, from their repo
Best whenWe're the main developer on an ongoing retainerTheir team leads and we contribute pieces

One GitHub detail decides a lot of this. Per Shopify's GitHub integration docs, only members of a GitHub organization with write access can connect branches to a store. Outside collaborators can't, and personal repos where you're a collaborator rather than the owner don't even show up in the list. If the client's repo is on a personal account, the client has to connect it themselves, or move it into an organization and add us as members.

Remotes: name them so nobody guesses

We use the same names everywhere. origin is where we push day to day. upstream is the client's repo, the source of truth. If the store runs on a vendor theme, that remote is named after the theme, like horizon. All of them go in the README, copied from git remote -v.

git remote add upstream git@github.com:client-org/client_shopify.git
git remote -v

Pulling the client's changes down

Before starting any work, and again before opening a pull request, we bring in everything the client's side has done, including the Shopify bot's commits from theme editor saves. It's the same fork-syncing process GitHub documents:

git fetch upstream
git switch main
git merge upstream/main
git push origin main

# then bring it into the branch you're working on
git switch feature/size-chart
git merge main

Pushing our work up

Unless the client tells us otherwise, we don't push straight to their main. Work goes up as a pull request against whichever branch they use for review, usually their staging branch, with the preview link and test notes in the description. Their developer or project lead merges it. That keeps them in control of what goes live, and it means our changes go through their review process, not around it.

If they have their own conventions for branch names, commit messages, or pull request templates, we follow theirs instead of ours.

Who owns the connected branch

Only the client's repo should be connected to the live theme. A theme connects to one branch at a time, so if our fork were the one connected, merchant edits would land in our repo instead of theirs and the client's history would fall behind. We connect our own repo only to unpublished dev themes, if at all.

Access and offboarding

We agree on these up front, in writing, so the end of a project is boring:

  • Shopify access .
    We access your store through a collaborator account, which the store owner can scope and remove at any time. Shopify's own guidance prohibits asking a merchant for their password, so we never do.
  • GitHub access.
    GitHub is only granted to named people, not shared logins, and removed when the work ends.
  • If we host the repo...
    We transfer it to the client's GitHub organization at the end of the engagement. Shopify suggests discussing who controls the repo before connecting it, and mentions transferring it as part of the final handoff. Test that the theme connection still syncs after any transfer, and remember that a disconnected branch can't be reconnected to the same theme.
  • The README and docs/changes.md are part of the handoff.
    We do this so the next developer knows exactly what was customized and how to take the next theme update.

What to expect when you work with us

A cedar trailhead kiosk with a map board and trail register box beside a marked trail leading into misty old-growth forest.

If you're a merchant, here's the short version of everything above. Your theme lives in GitHub, every change we make is recorded and reversible, and nothing reaches your live store until you've seen it.

  • You keep working in the theme editor like always.
    Change banners, rearrange your homepage, and edit content whenever you want. Those changes are saved to your live branch automatically, and our process is built to never overwrite them.
  • You'll get a staging preview before anything goes live.
    We'll send a link to a preview theme with the new work on it, plus a short list of what changed and what to check. You approve it, then we publish.
  • Small fixes move fast.
    An urgent fix can go straight to the live theme the same day, and we fold it into the next release so it isn't lost.
  • Theme updates are planned, not scary.
    When the theme developer releases an update, we merge it on a separate branch, test it on staging, and show it to you before it goes live. Your customizations come along with it.
  • You own your code.
    If you already have a GitHub organization, we work in your repo through pull requests your team can review. If you don't, we set one up for you, and it's yours at the end of the project.
  • Access is clean and temporary.
    We use a Shopify collaborator account, which you control and can remove any time, and we'll never ask for your password. When we're done, we hand over the repo, the README, and a list of every customization, and we remove our access.
  • Anyone can pick up where we left off.
    Because the history and documentation live with your code, another developer, in-house or otherwise, can take over without starting from scratch.

The short checklist

If you take one thing from this post, make it the habit of pulling main down before pushing anything up. Here's the rest on one page:

  • One repo per store, named client_shopify, with a README listing branches, themes, and remotes
  • main connected to the live theme, staging and dev to their own unpublished themes
  • Branch protection on main, with pull requests required and the Shopify GitHub app allowed to bypass
  • Merge main into staging and dev before every promotion
  • Treat settings_data.json, JSON templates, and market context files on main as the merchant's content
  • Start from an untouched vendor install, kept on vendor/horizon
  • Prefix every custom file, copy core sections instead of editing them, and log unavoidable core edits
  • Take vendor updates on an update/ branch and test them on staging
  • In a client-owned repo, upstream is theirs, and work goes in by pull request
  • Agree on access and handoff at the start, not the end

If you're a developer and want to compare notes, or a merchant who wants a theme you can actually maintain, get in touch.