deploteka ← All guides

By DeploTeka · Last updated August 12, 2026

Run one Express app as a fleet of dedicated Shopify apps

Short answer: @shopify/shopify-app-express's shopifyApp() binds one apiKey/apiSecretKey pair at construction, so a single instance can only ever speak for one Shopify app. deploteka-app-fleet-express keeps exactly the same public surface and turns every middleware into a dispatcher: it resolves which store the request belongs to, gets that store's own shopifyApp() instance, and runs it. One npm install, two edits, and every route line you already wrote keeps working. Budget 10–30 minutes.

This is the Express version of what npx deploteka onboard does automatically for Remix and React Router apps. Express doesn't get a codemod — there's no fixed template layout to rewrite safely — so the CLI detects the stack, prints these steps, and never touches your code.

What actually has to change

Running one codebase as many dedicated Shopify apps means your backend has to stop assuming there's one app. Three things:

  1. OAuth — begin and callback must run against the requesting store's client_id/secret, and redirect back to that store's app URL.
  2. Webhook HMACX-Shopify-Hmac-Sha256 must be verified with the requesting store's secret, not one shared one. Get this wrong and either every webhook fails, or worse, a signature from one tenant validates against another.
  3. App Bridge (embedded apps only) — the admin iframe must initialise with the requesting store's client_id.

Plus one thing that isn't about requests at all: somewhere durable to keep each store's credentials, and a way to receive new ones as DeploTeka provisions stores.

The adapter does all four. You wire it in two places.

1. Install

npm install deploteka-app-fleet-express

Peers: @shopify/shopify-app-express majors 4–8, express 4.17+ or 5.x. Published as both ESM and CommonJS, so a require()-based server loads it fine.

2. Add a credential table

This table is yours, in your database. DeploTeka writes to it through your app, never directly.

With Prisma:

model FleetStore {
  shop           String  @id
  clientId       String
  apiSecretKey   String
  applicationUrl String
}
npx prisma migrate dev --name fleet_store
import { prismaCredentialStore } from 'deploteka-app-fleet-express';

const store = prismaCredentialStore(prisma);

Without Prisma — most Express apps don't have it — create the equivalent table and implement the interface directly. It is two methods:

interface CredentialStore {
  get(shop: string): Promise<{ clientId: string; secret: string; appUrl: string } | null>;
  put(shop: string, creds: { clientId: string; secret: string; appUrl: string }): Promise<void>;
}

Anything satisfying that shape works — Knex, a raw pg query, Sequelize, Mongo, even a file for a first try:

const store = {
  async get(shop) {
    const row = await db.query('select client_id, secret, app_url from fleet_stores where shop = $1', [shop]);
    return row ? { clientId: row.client_id, secret: row.secret, appUrl: row.app_url } : null;
  },
  async put(shop, creds) {
    await db.query(
      `insert into fleet_stores (shop, client_id, secret, app_url) values ($1,$2,$3,$4)
       on conflict (shop) do update set client_id = $2, secret = $3, app_url = $4`,
      [shop, creds.clientId, creds.secret, creds.appUrl],
    );
  },
};

Note the on conflict do update: re-registering a shop is a normal operation, not an error. It's how secret rotation arrives.

3. Swap shopifyApp() for shopifyAppFleet()

Wherever you construct it — web/shopify.js in the Shopify template, server/shopify.ts, wherever yours lives:

-import { shopifyApp } from '@shopify/shopify-app-express';
+import { shopifyAppFleet, credentialResolver } from 'deploteka-app-fleet-express';

-const shopify = shopifyApp({
+const shopify = shopifyAppFleet({
   api: {
     apiVersion: LATEST_API_VERSION,
     restResources,
   },
   auth: { path: '/api/auth', callbackPath: '/api/auth/callback' },
   webhooks: { path: '/api/webhooks' },
   sessionStorage,
+  credentials: credentialResolver(store, async () => null),
+  baseCredentials: {
+    clientId: process.env.SHOPIFY_API_KEY,
+    secret: process.env.SHOPIFY_API_SECRET,
+    appUrl: process.env.HOST,
+  },
 });

Two things to notice about what you didn't write:

  • `apiKey` and `apiSecretKey` are gone from `api`. They're per-store now, injected when each store's instance is built. So is the app URL: Express expresses it as hostName/hostScheme, and the adapter derives both from each store's stored appUrl.
  • `sessionStorage` is now required. Base shopifyApp() quietly falls back to in-memory storage; the fleet needs one shared store across every per-store instance, or sessions and install state won't line up.

baseCredentials is your current env pair, unchanged. It's the fallback for any request whose shop can't be resolved, and for stores installed on your existing app before they were migrated — nothing 401s mid-migration.

The second argument to credentialResolver is an optional pull-through source, for fetching a store DeploTeka provisioned but whose row your table doesn't have yet. async () => null runs purely local-first; factoryCredentialSource({ url: process.env.FLEET_URL, token: process.env.FLEET_TOKEN }) enables pull-through. Either way the local table is read first, every time — a DeploTeka outage can't touch your data plane.

Every route line below this stays exactly as it is:

app.get(shopify.config.auth.path, shopify.auth.begin());
app.get(shopify.config.auth.callbackPath, shopify.auth.callback(), shopify.redirectToShopifyOrAppRoot());
app.post(shopify.config.webhooks.path, ...shopify.processWebhooks({ webhookHandlers }));
app.use('/api/*', shopify.validateAuthenticatedSession());
app.use(shopify.cspHeaders());
app.use('/*', shopify.ensureInstalledOnShop(), /* ... */);

That's the point of the swap: same names, same shapes, same order. shopify.config and shopify.api are still readable synchronously at boot, so route mounting is unaffected.

4. Mount the two fleet routes

import { createFleetRouter } from 'deploteka-app-fleet-express';

app.use(
  createFleetRouter({
    store,
    token: process.env.FLEET_REGISTER_TOKEN,
    sessionStorage,
    onRegistered: (shop) => shopify.invalidateShop(shop),
  })
);

That serves:

  • `POST /api/fleet/register` — DeploTeka hands your app a freshly provisioned store's credentials; they're upserted into your table. Bearer-gated, contractVersion: 1, idempotent.
  • `GET /api/fleet/installed?shop=` — DeploTeka reads install state and the secret attestation. Same bearer, read-only, Cache-Control: no-store.

Mount it before any auth middleware — it carries its own bearer token and must not sit behind validateAuthenticatedSession(). It needs no body parser of its own: it reads whatever your app already parsed, or drains the raw stream if nothing did.

Don't skip `onRegistered` — see the rotation section below for exactly what breaks without it.

5. Embedded apps: serve the right client_id to App Bridge

If your app renders an embedded admin UI, the page has to initialise App Bridge with the requesting store's client_id. In the Shopify Express template that's a string replacement on index.html:

-app.use('/*', shopify.ensureInstalledOnShop(), async (_req, res, _next) => {
+app.use('/*', shopify.ensureInstalledOnShop(), async (req, res, _next) => {
   return res
     .status(200)
     .set('Content-Type', 'text/html')
     .send(
       readFileSync(join(STATIC_PATH, 'index.html'))
         .toString()
-        .replace('%VITE_SHOPIFY_API_KEY%', process.env.SHOPIFY_API_KEY || '')
+        .replace('%VITE_SHOPIFY_API_KEY%', await shopify.getApiKey(req))
     );
 });

Wherever your app injects the API key — a template variable, a <meta> tag, a bootstrap JSON blob — replace the env read with await shopify.getApiKey(req). Get this wrong and the backend authenticates correctly while the iframe initialises against the wrong app identity, which is a confusing failure to debug.

Non-embedded apps skip this step entirely.

6. Environment

VariableRequiredWhat it does
FLEET_REGISTER_TOKENyesBearer gating both fleet routes. Strong, private, rotatable — a leak lets a caller overwrite a store's credentials. Record the same value on the app's DeploTeka card.
SHOPIFY_API_KEY, SHOPIFY_API_SECRET, HOSTyesKeep them. They're now baseCredentials — the fallback for unresolved shops and pre-migration installs.
FLEET_URL, FLEET_TOKENnoDeploTeka pull-through on a cache miss. Omit both to run local-first only.

How dispatch actually works

Worth understanding, because it explains both the behaviour and the one gotcha.

When a request arrives, the adapter resolves the shop, in this order: the X-Shopify-Shop-Domain header (the only signal a webhook POST carries), then the session-token JWT's dest/iss claim decoded unverified, then shop/host query params, then cookies or Referer as a last resort. It looks that shop's credentials up in your table, gets-or-builds that shop's shopifyApp() instance — cached by client_id, sharing your one session storage — and runs that instance's real middleware.

Instances are cheap and built once per store, lazily, on that store's first request. A hundred stores is a hundred small config objects sharing one session store and one process, not a hundred deployments.

For processWebhooks, the handler registration happens once per instance too, not once per request — the underlying library mounts your handlers as a side effect of building the middleware, and the adapter respects that.

Secret rotation: the one thing to get right

POST /api/fleet/register is an idempotent upsert, which makes it the path a rotated secret arrives on. A rotation keeps the same client_id — and client_id is the instance cache's key.

So without onRegistered, a store whose instance was already built keeps verifying against the old secret until the process restarts. Its webhooks start failing HMAC even though your table holds the correct value.

You might expect the automatic self-heal to catch this. It doesn't, and the reason is specific to Express: the adapter retries once on a thrown auth failure, but Express's Shopify middleware answers a bad webhook HMAC with a 401 response rather than throwing, so there's nothing to catch. This isn't theoretical — it's what happened the first time the adapter was run inside the real Shopify Express template.

onRegistered: (shop) => shopify.invalidateShop(shop) closes it: the moment DeploTeka pushes new credentials, that store's cached instance is dropped and the next request rebuilds it. One line, no restart. The hook fires only on a successful registration, and its own failures are swallowed — a cache problem can never turn a completed registration into an error DeploTeka would retry.

Verify before you point DeploTeka at it

Boot the app and run these against it. $APP_URL is wherever it's listening.

1. Register refuses an unauthenticated call:

curl -i -X POST "$APP_URL/api/fleet/register" \
  -H 'content-type: application/json' \
  -d '{"contractVersion":1,"shop":"acme.myshopify.com","clientId":"acme-id","secret":"acme-secret","appUrl":"https://acme-dedic.example.com"}'

Expect 401.

2. Register succeeds with the bearer:

curl -s -X POST "$APP_URL/api/fleet/register" \
  -H "authorization: Bearer $FLEET_REGISTER_TOKEN" -H 'content-type: application/json' \
  -d '{"contractVersion":1,"shop":"acme.myshopify.com","clientId":"acme-id","secret":"acme-secret","appUrl":"https://acme-dedic.example.com"}'

Expect {"contractVersion":1,"ok":true,"shop":"acme.myshopify.com"} — and a row in your table.

3. Installed reports the row before any install:

curl -s "$APP_URL/api/fleet/installed?shop=acme.myshopify.com" \
  -H "authorization: Bearer $FLEET_REGISTER_TOKEN"

Expect 200 with installed:false, your clientId, your appUrl, and a 16-character secretFingerprint — the first 16 hex characters of sha256(secret). DeploTeka computes the same value independently; a mismatch is the earliest signal that the wrong secret got stored.

4. Two shops dispatch to two apps — the actual feature. Register a second shop with different credentials, then:

curl -s -o /dev/null -D- "$APP_URL/api/auth?shop=acme.myshopify.com" | grep -i location
curl -s -o /dev/null -D- "$APP_URL/api/auth?shop=bravo.myshopify.com" | grep -i location

Each Location should point at that store's admin with that store's client_id and redirect_uri. If you see the same client_id twice, the swap in step 3 didn't take effect.

One wrinkle when testing by hand: @shopify/shopify-api answers 410 Gone to anything its bot detector flags, and curl's default User-Agent is flagged. Pass a browser User-Agent (-A 'Mozilla/5.0 ...') or you'll chase a phantom bug.

5. An unregistered shop falls back to your base app. Hit the same path with a shop you haven't registered; the Location should carry your SHOPIFY_API_KEY. That's the migration safety net working.

What DeploTeka does from here

Once the routes answer, DeploTeka provisions a dedicated Shopify app per store, pushes its credentials to /api/fleet/register, and polls /api/fleet/installed to move that store from Registered to Installed in the cabinet. Your app's deployment count stays at one.

If a store gets stuck on Registered, check the secretFingerprint first — a mismatch against the value DeploTeka computed at hand-off means the wrong secret was persisted, and it's a faster diagnosis than reading OAuth logs.

If your app doesn't look like this

The adapter assumes you call shopifyApp() somewhere and mount its middleware. If your Express app hand-rolls OAuth on @shopify/shopify-api directly, or wraps the Shopify library in your own abstraction, the adapter may not fit — but the underlying contract still does, and it is small. The framework-agnostic version is the fleet contract for any stack: two HTTP routes and a per-shop credential lookup, roughly 80 lines in any language.

Frequently asked questions

Which versions of @shopify/shopify-app-express does the adapter support?

Majors 4 through 8. Every part of the library the adapter touches — the shopifyApp() config keys and the returned auth.begin/callback, processWebhooks, validateAuthenticatedSession, cspHeaders, ensureInstalledOnShop, redirectToShopifyOrAppRoot and redirectOutOfApp — is identical in the published type declarations of 4.1.6, 5.0.20, 6.0.5, 7.0.1 and 8.0.0. Express itself may be 4.17+ or 5.x. The official Shopify Express template currently pins major 5, and that is the version the integration was verified against end to end.

Why is there no automated codemod for Express, when Remix gets one?

Because Express apps have no fixed layout to key a rewrite off. The Remix and React Router templates put shopifyApp() in a known file with known neighbours, so a codemod can find it, rewrite it, and verify the result compiles. The Shopify Express template's server is one hand-written file that every real app has since moved, split or rebuilt. Guessing where to write would risk a partial or corrupt edit, so the CLI detects the stack, prints the exact steps, and never touches your repository.

Does this work for a non-embedded or webhook-only Express app?

Yes, and it is less work. Non-embedded apps skip the App Bridge client_id step entirely — there is no iframe to initialise. What still matters is that OAuth and webhook HMAC verification use the requesting shop’s own secret, and both are handled by the same middleware swap. A webhook-only service needs only the fleet router and processWebhooks.

What happens to shops that are already installed on our existing app?

Nothing. The credentials you pass as baseCredentials — your current SHOPIFY_API_KEY / SHOPIFY_API_SECRET / HOST — remain the fallback for any shop the adapter cannot resolve to a dedicated app. Existing installs keep authenticating against the app they were installed on, and stores move to their own dedicated app as DeploTeka provisions them. There is no cutover moment.

Does the app depend on DeploTeka being online?

No. Credentials live in a table your app owns, and every lookup reads it directly. DeploTeka pushes new rows to POST /api/fleet/register; it is never called on the request path. Pull-through on a cache miss is available but optional, and even then the local table is checked first every time.

How do we know the integration actually worked before pointing DeploTeka at it?

Two curls and one log line. Register a fake shop and confirm you get 200 with the bearer and 401 without it; read GET /api/fleet/installed and confirm it returns your clientId, a 16-character secretFingerprint and installed:false. Then hit your OAuth begin path with ?shop= for two different registered shops and check that the Location header carries two different client_id values. That last one is the whole feature.