Run one Rails app as a fleet of dedicated Shopify apps
Short answer: the shopify_app gem reads api_key and secret from ShopifyApp.configuration, and hands them once at boot to ShopifyAPI::Context, a process-global singleton. One Rails process can therefore speak for exactly one Shopify app. To run a fleet — one dedicated Shopify app per merchant store, all served by the same deployment — you add two HTTP endpoints, one table, and a per-shop credential lookup at the three places the pair is actually read. It is roughly 150 lines, and npx deploteka onboard . --scaffold writes the first draft of all of them.
This is the Rails counterpart of what npx deploteka onboard does automatically for Remix and React Router apps. Rails does not get a codemod, and this guide is explicit about why.
Why one Rails app can only serve one Shopify app today
Run rails generate shopify_app:install and you get an initializer that, inside Rails.application.config.after_initialize, calls ShopifyAPI::Context.setup with ShopifyApp.configuration.api_key and .secret. Once, at boot. Everything downstream reads from there:
| What | Reads | From |
|---|---|---|
| OAuth / token exchange | client_id, client_secret | ShopifyAPI::Context |
| Session-token (JWT) verification | api_secret_key, then asserts aud == api_key | ShopifyAPI::Auth::JwtPayload |
| Webhook HMAC (controller) | ShopifyApp.configuration.secret, old_secret | ShopifyApp::PayloadVerification |
| Webhook HMAC (registry) | ShopifyAPI::Context.api_secret_key | ShopifyAPI::Utils::HmacValidator |
| Admin API calls | the shop's access token | the activated session |
Note the last row. Admin API calls are already per-shop: they authenticate with an access token, and the activated session that carries it is thread-local. That half of multi-tenancy already works. The problem is only the four rows above it — the client_id/secret pair.
The obvious fix, and why it is wrong
The obvious fix is an around_action that re-runs ShopifyAPI::Context.setup with the current shop's credentials. Do not ship that on a threaded server.
In shopify_api 16.3.0, lib/shopify_api/context.rb declares every configuration value as a plain class-level instance variable on the Context singleton — @api_key, @api_secret_key, @api_version, @host. Exactly one piece of its state is thread-local:
@active_session = T.let(Concurrent::ThreadLocalVar.new { nil }, T.nilable(Concurrent::ThreadLocalVar))
So under Puma with threads 5,5, request A sets shop A's secret, request B overwrites it with shop B's, and request A then verifies a JWT or an HMAC with the wrong key. Intermittently. Under load. In a way that reads as a Shopify outage.
There is a second cost even in the single-threaded case: Context.setup finishes by calling load_rest_resources, which unloads and reloads a Zeitwerk loader. That is a boot-time operation being run per request.
And there is a tell. The gem's own request-scoped switch, ShopifyApp::TokenExchange#activate_shopify_session, is an around_action — and it calls ShopifyAPI::Context.activate_session and deactivate_session, the thread-local pair. It never calls setup. Follow that lead.
What DeploTeka actually requires
Four things, and only the first two are endpoints.
- `POST /api/fleet/register` — DeploTeka pushes a freshly provisioned dedicated app's credentials to your app. You persist them.
- `GET /api/fleet/installed?shop=` — DeploTeka polls install state and verifies the secret/client_id binding.
- Per-shop credential resolution in your auth paths.
- A local replica so reads never depend on DeploTeka being reachable.
The wire shapes are frozen at contractVersion: 1 and are documented in full, language-neutrally, in the universal fleet contract guide. Everything below is the Rails-shaped version of them.
Step 1 — the table
class CreateFleetStores < ActiveRecord::Migration[8.0]
def change
create_table :fleet_stores do |t|
t.string :shop, null: false
t.string :client_id, null: false
t.string :secret, null: false
t.string :app_url, null: false
t.timestamps
end
add_index :fleet_stores, :shop, unique: true
end
end
This table is yours. DeploTeka writes rows into it over the register endpoint and never reads your database. The secret column holds a Shopify app's client secret, so if you have ActiveRecord::Encryption configured, declare encrypts :secret on the model.
The model carries three things the endpoints need — normalised lookup, the fingerprint, and the install predicate:
class FleetStore < ApplicationRecord
def self.for_shop(shop)
find_by(shop: shop.to_s.strip.downcase)
end
def self.register!(shop:, client_id:, secret:, app_url:)
record = find_or_initialize_by(shop: shop)
record.update!(client_id: client_id, secret: secret, app_url: app_url)
record
end
def secret_fingerprint
Digest::SHA256.hexdigest(secret.to_s)[0, 16]
end
def installed?
ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain(shop).present?
end
end
Two details that are not stylistic. Shop domains are lowercased on the way in and on every lookup, because a webhook header can arrive in any casing. And secret_fingerprint is pinned: sha256 of the raw secret, lowercase hex, first 16 characters. DeploTeka computes the same value to check that the secret it pushed is the secret you stored, without either side sending it again. Truncate it to a different length and provisioning fails an attestation it cannot explain.
The install predicate is the gem's own definition. ShopifyApp::EnsureInstalled decides the same question by calling ShopifyApp::SessionRepository.retrieve_shop_session_by_shopify_domain and treating a nil result as "not installed".
Step 2 — the two endpoints
class DeplotekaFleetController < ActionController::Base
skip_before_action :verify_authenticity_token, raise: false
SHOP_DOMAIN = /\A[a-z0-9][a-z0-9-]*\.myshopify\.com\z/i
def register
return method_not_allowed unless request.post?
return unauthorized unless bearer_ok?
begin
body = JSON.parse(request.raw_post)
rescue JSON::ParserError
return render(plain: "Bad Request", status: :bad_request)
end
unless body["contractVersion"] == 1
return render(json: { error: "UNSUPPORTED_CONTRACT_VERSION" }, status: :bad_request)
end
shop = body["shop"].to_s.strip.downcase
client_id = body["clientId"].to_s.strip
secret = body["secret"].to_s.strip
app_url = body["appUrl"].to_s.strip
if !shop.match?(SHOP_DOMAIN) || client_id.empty? || secret.empty? || app_url.empty?
return render(plain: "Invalid credentials payload", status: :bad_request)
end
FleetStore.register!(shop: shop, client_id: client_id, secret: secret, app_url: app_url)
render json: { contractVersion: 1, ok: true, shop: shop }, status: :ok
end
def installed
return method_not_allowed unless request.get? || request.head?
return unauthorized unless bearer_ok?
shop = params[:shop].to_s.strip.downcase
return render(plain: "Invalid shop", status: :bad_request) unless shop.match?(SHOP_DOMAIN)
store = FleetStore.for_shop(shop)
return render(json: { error: "Store not registered" }, status: :not_found) if store.nil?
is_installed = store.installed?
response.set_header("Cache-Control", "no-store")
render json: {
contractVersion: 1,
installed: is_installed,
clientId: store.client_id,
secretFingerprint: store.secret_fingerprint,
appUrl: store.app_url,
grantedScopes: is_installed ? store.granted_scopes : [],
}, status: :ok
end
private
def bearer_ok?
expected = ENV["FLEET_REGISTER_TOKEN"].to_s
return false if expected.empty?
presented = request.headers["Authorization"].to_s.strip[/\ABearer\s+(.+)\z/i, 1].to_s.strip
ActiveSupport::SecurityUtils.secure_compare(presented, expected)
end
def unauthorized = render(plain: "Unauthorized", status: :unauthorized)
def method_not_allowed = render(plain: "Method Not Allowed", status: :method_not_allowed)
end
Routes, both with via: :all:
match "/api/fleet/register", to: "deploteka_fleet#register", via: :all
match "/api/fleet/installed", to: "deploteka_fleet#installed", via: :all
Four things worth pausing on:
- `via: :all`, and the method checked in the controller. The contract's answer to a wrong method is the plain-text body
Method Not Allowed. If the router rejects it instead, Rails renders an HTML error page, and DeploTeka compares bodies. - Order. Method, then bearer, then JSON, then contract version, then payload. A GET with a perfectly valid token is still a 405.
- `secure_compare`, not `fixed_length_secure_compare`. The presented token is attacker-controlled and can be any length; the fixed-length helper raises
ArgumentErroron a mismatch.secure_comparecompares byte sizes first. This is the same helper the gem uses for webhook HMACs. - 404 versus `installed: false`. 404 means "no row" — never registered. Once a row exists, its facts are always returned, even before any install, because DeploTeka reads the clientId and fingerprint back to confirm its own write landed.
Set FLEET_REGISTER_TOKEN in the environment and record the same value on the DeploTeka app card. An unset token means both routes reject everything: it fails closed, never open.
Step 3 — the three credential injections
This is the part that turns registration into a working fleet, and it is three small replacements rather than one large one.
Resolve credentials with a fallback to the base app, so nothing changes for shops that do not have a dedicated app yet:
def self.credentials_for(shop)
store = FleetStore.for_shop(shop)
return { client_id: store.client_id, secret: store.secret } if store
{ client_id: ShopifyApp.configuration.api_key, secret: ShopifyApp.configuration.secret }
end
Injection 1 — webhook HMAC
shopify_app verifies webhooks in ShopifyApp::PayloadVerification#hmac_valid?, which computes a base64 SHA-256 HMAC over the raw body using ShopifyApp.configuration.secret (and old_secret if set), compared with ActiveSupport::SecurityUtils.secure_compare. It is one method, and the shop domain arrives in the X-Shopify-Shop-Domain header, so overriding it is the whole job:
class FleetWebhooksController < ActionController::Base
include ShopifyApp::WebhookVerification
private
def hmac_valid?(data)
presented = request.headers["HTTP_X_SHOPIFY_HMAC_SHA256"].to_s
return false if presented.empty?
shop = request.headers["HTTP_X_SHOPIFY_SHOP_DOMAIN"].to_s.strip.downcase
secret = credentials_for(shop)[:secret].to_s
return false if secret.empty?
digest = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), secret, data)
ActiveSupport::SecurityUtils.secure_compare(presented, Base64.strict_encode64(digest))
end
end
One caveat, and it is the most likely thing to confuse you later. shopify_app's own WebhooksController runs two verifications: this concern's check, and then ShopifyAPI::Webhooks::Registry.process, which validates again using ShopifyAPI::Context.api_secret_key — the process-global one, hex-encoded rather than base64. Overriding hmac_valid? does not affect that second check. Receive fleet webhooks in your own controller and handle them directly instead of routing them through the Registry.
Injection 2 — session tokens
ShopifyAPI::Auth::JwtPayload decodes the embedded app's session token with Context.api_secret_key and then asserts that the token's aud equals Context.api_key. Both are global, so it cannot verify a token minted by a per-shop dedicated app. Decode it yourself:
def decode_session_token(authorization_header)
token = authorization_header.to_s[/\ABearer\s+(.+)\z/i, 1].to_s.strip
return nil if token.empty?
unverified = JWT.decode(token, nil, false).first
shop = unverified["dest"].to_s.sub("https://", "").strip.downcase
creds = credentials_for(shop)
payload = JWT.decode(token, creds[:secret], true, algorithm: "HS256", leeway: 10).first
payload["aud"] == creds[:client_id] ? payload : nil
rescue JWT::DecodeError
nil
end
Reading the dest claim before verification looks alarming and is not: you are using an unverified claim to choose a key, then verifying for real. What would be unsafe is acting on an unverified claim. The aud check afterwards is what binds the token to the dedicated app that issued it. HS256 and the ten-second leeway match what the gem uses.
Injection 3 — OAuth and token exchange
This is the only place that genuinely needs the pair, and the only one ShopifyAPI::Auth cannot be persuaded to take one. It is also low-frequency — once per install, plus refreshes — so hand-rolling it costs little. Both grants are form POSTs to the shop's own domain:
def exchange_token(shop:, session_token:)
creds = credentials_for(shop)
post_oauth(shop,
client_id: creds[:client_id],
client_secret: creds[:secret],
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: session_token,
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token")
end
def post_oauth(shop, form)
response = Net::HTTP.post_form(URI("https://#{shop}/admin/oauth/access_token"), form)
raise "Shopify OAuth failed for #{shop}: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
Store the resulting token the way your app already does — through ShopifyApp::SessionRepository if you use the stock storage — so installed? keeps telling the truth.
The fallback, if your app cannot be restructured
If you cannot take the three injections, and your deployment is single-threaded per process — Puma threads 1,1, or Unicorn or Passenger in process mode — then a mutex-guarded around_action that swaps Context and restores it in an ensure block will work. It serialises every request that touches Shopify and pays a Zeitwerk reload per call, so it is slow as well as fragile, and it is silently wrong the moment somebody raises the thread count.
It is worth knowing about because being captured as you are beats not being captured. It is not the recipe. --scaffold generates it, clearly labelled, at the bottom of the credentials file.
Generate the starting point
npx deploteka onboard . --scaffold
The CLI reads your Gemfile and Gemfile.lock, recognises shopify_app, and writes into deploteka-fleet/: the migration, the model, the controller, the routes snippet, and the credentials concern with all three injections and the labelled fallback. It writes nowhere else, it never overwrites an existing file, and every generated file carries TODO(deploteka) markers at the lines only you can finish. Running it twice is safe and does nothing the second time.
Order of work
- Migration, model, controller, routes,
FLEET_REGISTER_TOKEN. Verify with the curls below and stop here to check. At this point DeploTeka can provision dedicated apps for your stores, even though nothing per-shop is wired yet. - Webhook HMAC. Smallest, and immediately testable with a signed request.
- Session-token verification, if the app is embedded.
- OAuth and token exchange.
Verification checklist
curl -i -X POST "$APP_URL/api/fleet/register" \
-H "authorization: Bearer $FLEET_REGISTER_TOKEN" -H "content-type: application/json" \
-d '{"contractVersion":1,"shop":"test.myshopify.com","clientId":"x","secret":"y","appUrl":"https://your-app.example.com"}'
Expect 200 with {"contractVersion":1,"ok":true,"shop":"test.myshopify.com"}, and 401 when you drop the bearer.
curl -s "$APP_URL/api/fleet/installed?shop=test.myshopify.com" \
-H "authorization: Bearer $FLEET_REGISTER_TOKEN"
Expect 200 carrying your clientId, a 16-character secretFingerprint, the appUrl, and installed:false. Then ask for a shop you never registered and expect 404, not an empty 200.
The last check is the real one: sign a webhook body with a dedicated app's secret, send it with that shop's X-Shopify-Shop-Domain header, and confirm it verifies — while the same body signed with the base app's secret does not.
What this does not cover
- Multiple credential pairs inside a single OAuth callback URL. Your callback path is shared across the fleet; the shop parameter is what disambiguates it. If your callback derives the app identity from anything other than the shop, that has to change first.
- Granted scopes per shop, unless your
shopstable has theaccess_scopescolumn, whichshopify_appships as a separate opt-in migration. Without it, report[]; DeploTeka tolerates it. - The Registry webhook path, as described above.
- App proxy signatures, verified by
ShopifyApp::AppProxyVerificationagainst the configured secret. If you use app proxies, that is a fourth injection with the same shape as the webhook one.