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:
- OAuth — begin and callback must run against the requesting store's
client_id/secret, and redirect back to that store's app URL. - Webhook HMAC —
X-Shopify-Hmac-Sha256must 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. - 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 storedappUrl. - `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
| Variable | Required | What it does |
|---|---|---|
FLEET_REGISTER_TOKEN | yes | Bearer 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, HOST | yes | Keep them. They're now baseCredentials — the fallback for unresolved shops and pre-migration installs. |
FLEET_URL, FLEET_TOKEN | no | DeploTeka 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.