VPS Snaps

How to back up a Shopify store

Shopify runs the servers, but it does not give you a copy of your store or a button that puts it back the way it was yesterday. Its own backup page points you to CSV exports, a theme download and backup apps, and a deleted product cannot be restored. This guide covers what the admin exports and what it misses, how to copy the theme, how to export everything else nightly through the API, and what you can and cannot import again.

17 min readUpdated Checked against official documentation

What Shopify keeps for you, and what it doesn't

Shopify's Help Center page on backups is called Backups and duplication. It says you can export CSV files to back up store information, download your theme, or use an app from the Shopify App Store. It describes no snapshot of your store and no way to roll it back to an earlier day. Mistakes are final: "When you delete a product, it's permanently removed from Shopify. Deleted products can't be restored." Deleted orders are removed from the admin and from reports.

So a Shopify backup means three copies you make yourself: the CSV exports, the theme files, and an API export of everything the CSVs leave out. Shopify's CSV help lists what the admin can export and import:

DataExportImport back
ProductsYesYes, CSV up to 15 MB
CustomersYesYes, CSV up to 15 MB, no passwords
InventoryYesYes, CSV up to 15 MB
URL redirectsYesYes
OrdersYesNo, not through the admin
DiscountsYesNo
Gift cardsYes, last 4 characters of each code onlyNo
ThemeDownload as a .zip, or Shopify CLIYes, upload the .zip or push with the CLI
Pages, blog posts, menus, metaobjects, Content > Files, policies, translationsNo CSVNo CSV

Archive a product instead of deleting it when you might want it back; an archived product moves to the Archived tab and returns as a draft. A deleted product only comes back by importing it again from your own CSV, as a new product with a new ID.

Export the CSVs from the admin

Shopify emails most of these exports instead of downloading them: the file goes to you and, if you are staff, to the store owner too. Choose Plain CSV file where the dialog offers it. If you open a file in a spreadsheet program, save it back as UTF-8, the only encoding Shopify imports, and don't sort a product CSV: Shopify warns that sorting can separate images from their products.

ExportWhereNotes
ProductsProducts > Export > All products > Plain CSV file > Export productsEmailed. Holds image URLs, not the image files.
CustomersCustomers > Export > All customersMore than 50 is emailed. Tick Customer tags and Customer metafields.
OrdersOrders > Export > Orders by date > Export ordersEmailed when more than 50 or by date. Export transaction histories separately.
InventoryProducts > Inventory > Export, All statesOne row per location: Incoming, Unavailable, Committed, Available, On hand.
Gift cardsProducts > Gift cards > ExportMore than 50 is emailed. Only the last 4 characters of each code.
URL redirectsContent > Menus > URL redirects > ExportUp to 100,000 redirects per store, 20,000,000 on Plus.

Read the product CSV's gaps before you rely on it. Images are URLs in the Product image URL and Variant image URL columns; on import Shopify downloads them and uploads them again, so a URL that no longer works means a product without pictures. Download the image files too, or keep the API export below, which lists every media URL. Product metafields are included once they are defined, as product.metafields.namespace.key columns, but variant metafields are not. Collections are not exported at all, and the inventory column only works for a single location; multi-location stores need the inventory CSV.

The customer CSV has no passwords, and only some metafield types export: boolean, date, date and time, integer, decimal and single line text. The order CSV puts each extra line item on its own row, its Discount Code column leaves out automatic discounts, and its transaction history includes captured payments only. Shopify's estimate for a large order export is under an hour for fewer than 100,000 items and around 4 hours for 400,000.

Back up the theme

The theme's files hold more than code. In an Online Store 2.0 theme, config/settings_data.json and the JSON templates hold the colours, sections and text you set in the theme editor, so a theme copy brings those back. It does not include products, collections, menus, pages, blog posts, images or Content > Files.

  • Duplicate: Online Store, open the theme's actions menu, Duplicate. The copy sits in your theme library, which holds 20 themes on Basic, Grow and Advanced and 100 on Plus; the Starter plan can only add the Spotlight theme. A duplicate lives inside the same store, so it protects you from your own edits, not from losing the store.
  • Download theme file: Online Store, the theme's actions menu, Download theme file. Shopify emails a .zip to the address on your staff login.
  • Timeline: the code editor keeps a per-file history you can restore one file at a time. The history is finite, and files in assets (images, CSS, JavaScript, fonts) have none.
  • Shopify CLI: pulls every theme file into a folder on your machine, which you can commit to Git. It needs Node.js 22.12 or later and Git 2.28.0 or later.
Terminal
npm install -g @shopify/cli@latest
shopify theme pull --store your-store --live --path ./theme-2026-10-29

--store takes the store prefix or the full your-store.myshopify.com. --live pulls the published theme; use --theme with a theme's ID or name for another one. The first run signs you in through the browser. Without --live, --development or --theme the CLI asks which theme you mean, which a script cannot answer.

What no CSV covers

Check this list against your store. Anything on it that you have built by hand exists only in Shopify until you export it through the API:

  • Pages and blog posts, and the store's legal policies.
  • Navigation menus. URL redirects do have their own CSV.
  • Collections, including smart collection rules. The product CSV can add products to a collection on import, but it doesn't export membership.
  • Metaobjects and their definitions, and variant metafields.
  • Uploaded files under Content > Files, and product images as files.
  • Translations and Markets settings.
  • Discount codes and issued gift cards as something you can restore. Shopify says both "can't be transferred".
  • Store settings: shipping, taxes, checkout, notifications, staff. Note them by hand or with screenshots.
  • Data that apps keep on their own servers. Shopify's exports only cover Shopify's own data, so check each installed app for its own export.

Get an API token from the Dev Dashboard

Since January 1, 2026, a new custom app can no longer be made in the store admin. Apps made there before that date keep working, with their static Admin API token, and are still managed from the admin. New ones are made in the Dev Dashboard at dev.shopify.com. Making one needs the organization-level App developer role; collaborator accounts cannot open the Dev Dashboard.

  1. In the Dev Dashboard, open Apps, click Create app, choose Start from Dev Dashboard, name it and click Create.
  2. On the app's Versions page, select the read scopes from the table below, then click Release. An app needs a released version before it can be installed.
  3. Click Install app and choose the store.
  4. In App settings, copy the Client ID and Client secret from the Credentials card. Treat the secret like a password.
To exportTop-level queryScope
Products, variants, product metafields, mediaproductsread_products
Collectionscollectionsread_products
Customerscustomersread_customers
Ordersordersread_orders, plus read_all_orders for orders older than 60 days
Inventory and locationslocationsread_inventory, read_locations
Pages, blogs and articlespages, blogs, articlesread_content
Navigation menusmenusread_online_store_navigation
URL redirectsurlRedirectsread_online_store_navigation
Content > Filesfilesread_files
Metaobjectsmetaobjects(type: "…")read_metaobjects, read_metaobject_definitions
DiscountsdiscountNodesread_discounts
Gift cardsgiftCardsread_gift_cards

read_all_orders needs no approval from Shopify on an app made in the store's own organization; apps made under a Partner organization have to request it first. Add read_legal_policies, read_translations and read_markets if you want those too. A scope added later reaches the store only after you release a new version and approve the change on the store.

For a store in the same organization as the app, swap the client ID and secret for an access token with the client credentials grant. The request is form-encoded; Shopify ignores a scope parameter. The token lasts 24 hours: expires_in is always 86399.

Terminal
export SHOP=your-store
export CLIENT_ID='client-id-from-app-settings'
export CLIENT_SECRET='client-secret-from-app-settings'

TOKEN=$(curl -fsS -X POST "https://$SHOP.myshopify.com/admin/oauth/access_token" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" | jq -er .access_token)

The response's scope field lists what the store actually granted, which can be fewer than you asked for. Send the token in the X-Shopify-Access-Token header. Owning a store does not by itself put it in your organization: "same organization" means both appear under the same organization in the Dev Dashboard. The grant cannot reach a client's store at all; see the checklist below.

Customer names, emails, phone numbers and addresses are protected customer data. Shopify's rules make them always available to custom apps, while public apps need a review. Without that access the API does not fail outright: it returns null for those fields with an entry in errors. Query one customer normally before trusting a customer or order export.

Export products with a bulk operation

Paging through a large store one request at a time runs into Shopify's rate limits. A bulk operation runs the query on Shopify's side and gives you one JSONL file, one object per line. API version 2026-10 is the latest stable version (supported until October 16, 2027), and from 2026-01 each app can run up to five bulk queries per store at once. The examples use curl and jq 1.6 or later (apt install curl jq). Save the query to a file first:

products.graphql
{
  products {
    edges {
      node {
        id
        handle
        title
        status
        vendor
        productType
        tags
        descriptionHtml
        seo { title description }
        metafields { edges { node { namespace key type value } } }
        variants {
          edges {
            node { id title sku price compareAtPrice inventoryQuantity selectedOptions { name value } }
          }
        }
        media { edges { node { id alt mediaContentType preview { image { url } } } } }
      }
    }
  }
}

A bulk query has one top-level field, at most five connections and at most two levels of nested connections; this one uses four connections, one level deep. Start it with the TOKEN from the step above:

Terminal
API="https://$SHOP.myshopify.com/admin/api/2026-10/graphql.json"

jq -nc --rawfile q products.graphql \
  '{query: "mutation($q: String!) { bulkOperationRunQuery(query: $q) { bulkOperation { id status } userErrors { field message } } }", variables: {q: $q}}' \
| curl -fsS -X POST "$API" \
    -H "Content-Type: application/json" \
    -H "X-Shopify-Access-Token: $TOKEN" \
    --data-binary @- | jq .

The answer carries an ID such as gid://shopify/BulkOperation/720918 and the status CREATED. Ask for it with bulkOperation(id:); from API version 2026-01 that replaces the deprecated currentBulkOperation. Repeat until the status is COMPLETED; FAILED and CANCELED mean it stopped.

Terminal
OP_ID='gid://shopify/BulkOperation/720918'

jq -nc --arg id "$OP_ID" \
  '{query: "query($id: ID!) { bulkOperation(id: $id) { status errorCode objectCount url } }", variables: {id: $id}}' \
| curl -fsS -X POST "$API" \
    -H "Content-Type: application/json" \
    -H "X-Shopify-Access-Token: $TOKEN" \
    --data-binary @- | jq .

When it completes, url is a signed link to the JSONL file. Download it with no token header; the link expires 7 days after the operation completes, and it is null when the query matched nothing:

Terminal
curl -fsS -o "products-$(date -u +%Y-%m-%d).jsonl" 'https://storage.googleapis.com/shopify/…the url from the answer…'

Variants, metafields and media come out as their own lines after their product, each with a __parentId holding the product's ID, in the same order as the connection. A query has 10 days to finish before Shopify marks it failed. If it fails with errorCode ACCESS_DENIED, a field needs a scope the app doesn't have; run the same query as a normal request to see which, and check partialDataUrl for what was exported before the failure.

Run the export every night

On a Linux server, this script asks for a fresh token every run, so the 24-hour expiry never matters. It runs one bulk query per .graphql file in a folder, pulls the live theme, packs the lot into one dated archive under a temporary name, and keeps 30 days. Put products.graphql from above in the folder, then one file for each other top-level query in the scopes table, for example customers.graphql:

/etc/shopify-backup/queries/customers.graphql
{
  customers {
    edges {
      node {
        id
        firstName
        lastName
        defaultEmailAddress { emailAddress marketingState }
        tags
        createdAt
        defaultAddress { address1 address2 city province zip country phone }
      }
    }
  }
}
/etc/shopify-backup/queries/orders.graphql
{
  orders {
    edges {
      node {
        id
        name
        createdAt
        email
        displayFinancialStatus
        displayFulfillmentStatus
        totalPriceSet { shopMoney { amount currencyCode } }
        customer { id }
        lineItems { edges { node { title sku quantity originalUnitPriceSet { shopMoney { amount currencyCode } } } } }
      }
    }
  }
}

The theme pull uses a password from Shopify's free Theme Access app, which is made for the CLI in scripts: install it, click Create theme password, and open the emailed link within 7 days; the password is shown once. It carries write_themes, so guard it like the client secret. Keep all four secrets in one file only root can read:

/etc/shopify-backup/env
SHOPIFY_SHOP=your-store
SHOPIFY_CLIENT_ID=client-id-from-app-settings
SHOPIFY_CLIENT_SECRET=client-secret-from-app-settings
SHOPIFY_CLI_THEME_TOKEN=password-from-theme-access
/usr/local/bin/shopify-backup.sh
#!/bin/bash
set -euo pipefail

set -a
. /etc/shopify-backup/env
set +a

API="https://$SHOPIFY_SHOP.myshopify.com/admin/api/2026-10/graphql.json"
QUERIES="/etc/shopify-backup/queries"
BACKUP_DIR="/var/backups/shopify"
KEEP_DAYS=30
STAMP=$(date -u +%Y-%m-%d_%H%M)
WORK="$BACKUP_DIR/.work-$STAMP"
OUT="$BACKUP_DIR/shopify-$SHOPIFY_SHOP-$STAMP.tar.gz"

mkdir -p "$WORK/theme"
trap 'rm -rf "$WORK" "$OUT.partial"' EXIT

# A new token on every run: each one lasts 24 hours.
TOKEN=$(curl -fsS -X POST "https://$SHOPIFY_SHOP.myshopify.com/admin/oauth/access_token" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_id=$SHOPIFY_CLIENT_ID" \
  --data-urlencode "client_secret=$SHOPIFY_CLIENT_SECRET" | jq -er .access_token)

gql() {
  curl -fsS -X POST "$API" \
    -H "Content-Type: application/json" \
    -H "X-Shopify-Access-Token: $TOKEN" \
    --data-binary @-
}

for file in "$QUERIES"/*.graphql; do
  name=$(basename "$file" .graphql)

  started=$(jq -nc --rawfile q "$file" \
    '{query: "mutation($q: String!) { bulkOperationRunQuery(query: $q) { bulkOperation { id } userErrors { field message } } }", variables: {q: $q}}' | gql)
  id=$(jq -er '.data.bulkOperationRunQuery.bulkOperation.id' <<<"$started") \
    || { echo "$name: $started" >&2; exit 1; }

  while :; do
    sleep 10
    op=$(jq -nc --arg id "$id" \
      '{query: "query($id: ID!) { bulkOperation(id: $id) { status errorCode objectCount url } }", variables: {id: $id}}' | gql)
    case $(jq -r '.data.bulkOperation.status' <<<"$op") in
      COMPLETED) break ;;
      CREATED|RUNNING) ;;
      *) echo "$name: $op" >&2; exit 1 ;;
    esac
  done

  url=$(jq -r '.data.bulkOperation.url // empty' <<<"$op")
  if [ -n "$url" ]; then
    curl -fsS -o "$WORK/$name.jsonl" "$url"
  else
    : > "$WORK/$name.jsonl"   # the query matched nothing
  fi
  echo "$name: $(jq -r '.data.bulkOperation.objectCount' <<<"$op") objects"
done

# The live theme; the CLI reads the Theme Access password from SHOPIFY_CLI_THEME_TOKEN.
shopify theme pull --store "$SHOPIFY_SHOP" --live --path "$WORK/theme" --no-input

tar -czf "$OUT.partial" -C "$WORK" .
mv "$OUT.partial" "$OUT"

find "$BACKUP_DIR" -name "shopify-$SHOPIFY_SHOP-*.tar.gz" -type f -mtime +"$KEEP_DAYS" -delete
Terminal
chmod 600 /etc/shopify-backup/env
chmod 755 /usr/local/bin/shopify-backup.sh
/usr/local/bin/shopify-backup.sh
/etc/cron.d/shopify-backup
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

30 2 * * * root flock -n /run/lock/shopify-backup.lock /usr/local/bin/shopify-backup.sh >> /var/log/shopify-backup.log 2>&1

set -o pipefail makes a failed token request or download stop the run instead of leaving a half-written file, a failed bulk operation prints Shopify's answer to the log, and the archive only gets its real name once it is complete. flock -n stops two runs overlapping. The queries run one after another, which keeps well inside the five-at-once limit. A copy on the same server is not a backup of that server, so send the archives somewhere else, for example with rclone, and see cron backup schedules for picking the time.

What you can put back, and what you can't

DataHow it goes backWhat doesn't come back
ProductsProducts > Import > Add file, tick Overwrite products with matching handles, Import productsCollections membership, variant metafields, the original product IDs of deleted products
InventoryProducts > Inventory > Import, CSV up to 15 MBQuantities only for locations that exist in the store
CustomersCustomers > Import > Add file > Import customersPasswords
ThemeOnline Store > Draft themes > Import theme > Upload zip file, or shopify theme push --unpublishedTimeline history
URL redirectsContent > Menus > URL redirects > ImportNothing
OrdersNot through the admin. Shopify points to apps or the API and calls moving orders "not supported by Shopify"Keep the export as your record
Discount codes, issued gift cardsThey can't be transferredRecreate them by hand
Pages, posts, menus, metaobjects, filesBy hand from the JSONL, or through the API with the matching write scopesThe original IDs; each item is created again

With Overwrite products with matching handles ticked, an empty cell in a column that is present blanks that field on the live product, changing Option1 to Option3 values deletes the variants' IDs and makes new ones, and an import cannot be cancelled or undone. Import into a test store first, or import only the rows you need.

Customer imports keep the Accepts Email Marketing yes or no column, so marketing consent comes back. Ticking Overwrite existing customers that have the same email or phone number replaces all of that customer's data. With classic customer accounts, invite imported customers to set a new password; with new customer accounts, customers can sign in with a one-time 6-digit code instead of a password.

For a theme, push to a new unpublished theme, check it in the preview, then publish it. shopify theme push deletes files on the remote theme that are not in your folder, so add --nodelete when you are restoring a few files into an existing theme:

Terminal
shopify theme push --store your-store --unpublished --theme "Restored 2026-10-29" --path ./theme-2026-10-29

Test this on a development store before you need it: import the products and customers CSVs, push the theme, and compare product and customer counts with the live store. Testing restores shows how to make that routine.

Before you work on someone's store

Freelancers and agencies usually get a collaborator account, which cannot open the Dev Dashboard, and the client credentials grant cannot reach a store outside your own organization. An app made in your own organization with custom distribution, installed by the client from its install link, is different: the store approves it once, and you then get its token with the authorization code grant. Without either, the admin and the CLI are your tools. Before the first change:

  1. Duplicate the live theme and make your changes on the copy. Publish it only when it is ready, and keep the old theme unpublished as your way back.
  2. Export products, customers and orders as CSV, and inventory if the store has more than one location. Wait for the emailed files before you start.
  3. Pull the live theme with shopify theme pull --live into a folder you commit to Git.
  4. List every installed app and its settings, with screenshots, and note which ones add code to the theme.
  5. Export URL redirects, and copy any pages or menus you will touch.
  6. Keep all of it outside the store, in storage you or the client control. A duplicate theme is lost with the store.

For a regular backup of a client's store, the owner can give you a staff account with the App developer role so you can make the app in their organization, or make it themselves and send you the credentials.

Common errors

ErrorFix
Invalid API key or access tokenYou sent the client ID or secret, or a token older than 24 hours. Request a new token and send it in X-Shopify-Access-Token.
The token request fails for a client's storeClient credentials only work when the app and the store are in the same organization. Make the app in the store's organization.
Orders stop 60 days backAdd read_all_orders, release a new version and approve it on the store.
A new scope isn't in the token's scopeScope changes reach the store only through a released version the store approves.
Bulk operation FAILED with ACCESS_DENIEDA field needs a missing scope. Run the query normally to find it.
Customer names and emails are nullThe app has no protected customer data access; the reason is in the response's errors.
bulkOperationRunQuery returns no operationRead its userErrors. One cause: five bulk queries are already running for this app on the store; wait for one to finish or cancel it.
The product import fails or times outThe CSV is over 15 MB. Split it into smaller files.
shopify theme pull stops to ask which themeIn a script, pass --live, --development or --theme.

Frequently asked questions

Does Shopify back up my store?
Not in a way you can restore from. Shopify's backup page tells you to export CSV files, download your theme or use an app, and deleted products can't be restored.
Can I restore a deleted Shopify product?
Not from Shopify. Import it again from a product CSV you exported earlier; it comes back as a new product with a new ID. Archive products you might want later instead of deleting them.
Can I import orders into Shopify from a CSV?
No. Orders can be exported but not imported through the admin. Keep the export as a record; moving orders needs an app or the API, which Shopify doesn't support for migrations.
Do customer passwords survive a Shopify export?
No. The customer CSV has no passwords. With classic customer accounts, invite customers to set a new one; with new customer accounts they sign in with an emailed code.
Can I still create a custom app in the Shopify admin?
Not since January 1, 2026. Existing ones keep working. New apps are made in the Dev Dashboard and get tokens with the client credentials grant, which last 24 hours.
How often should I back up a Shopify store?
Export nightly at least, and always just before a theme change, a bulk product import or a new app. Shopify gives you no earlier copy to fall back on, so your newest export is your restore point.

How this was checked

Commands, limits and prices were checked against these official pages, on October 29, 2026: