deploteka ← All guides

By DeploTeka · Last updated August 13, 2026

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:

WhatReadsFrom
OAuth / token exchangeclient_id, client_secretShopifyAPI::Context
Session-token (JWT) verificationapi_secret_key, then asserts aud == api_keyShopifyAPI::Auth::JwtPayload
Webhook HMAC (controller)ShopifyApp.configuration.secret, old_secretShopifyApp::PayloadVerification
Webhook HMAC (registry)ShopifyAPI::Context.api_secret_keyShopifyAPI::Utils::HmacValidator
Admin API callsthe shop's access tokenthe 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.

  1. `POST /api/fleet/register` — DeploTeka pushes a freshly provisioned dedicated app's credentials to your app. You persist them.
  2. `GET /api/fleet/installed?shop=` — DeploTeka polls install state and verifies the secret/client_id binding.
  3. Per-shop credential resolution in your auth paths.
  4. 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 ArgumentError on a mismatch. secure_compare compares 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

  1. 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.
  2. Webhook HMAC. Smallest, and immediately testable with a signed request.
  3. Session-token verification, if the app is embedded.
  4. 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 shops table has the access_scopes column, which shopify_app ships 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::AppProxyVerification against the configured secret. If you use app proxies, that is a fourth injection with the same shape as the webhook one.

Frequently asked questions

Can I just call ShopifyAPI::Context.setup in an around_action?

Not safely on a multi-threaded server. In shopify_api 16.3.0 every value Context.setup writes — api_key, api_secret_key, api_version, host — is a plain class-level instance variable on the Context singleton. Exactly one piece of its state is thread-local: active_session, which is a Concurrent::ThreadLocalVar. Under Puma with more than one thread, two concurrent requests for two different dedicated apps overwrite each other mid-request, and the symptom is intermittent HMAC and JWT failures that look like Shopify errors. Context.setup also ends by unloading and reloading a Zeitwerk loader for the REST resources, which is not something you want on a per-request path. The gem itself never does this: its own around_action calls Context.activate_session and deactivate_session, the thread-local pair.

Which version of shopify_app and shopify_api does this work with?

The recipe is written against shopify_app 23.0.3 and shopify_api 16.3.0, the current releases. Nothing in it depends on a recent addition: the config accessors, the webhook verification concern and the session repository have had these names for many majors. Two version facts do matter. Context.setup no longer accepts session_storage — that was removed in shopify_api 13.0.0 — so any older recipe passing it raises an ArgumentError. And ShopifyApp::ShopSessionStorageWithScopes is deprecated with removal announced for v24.0.0; use ShopifyApp::ShopSessionStorage, which handles access scopes and token expiry itself.

Do I have to upgrade anything first?

No. DeploTeka captures apps as they are. The two contract endpoints are a plain controller and a migration, which work on any Rails version that runs at all, and the credential injections replace methods that have existed in shopify_app for years. If you are on a version old enough that a name below does not exist, the universal contract guide has the same logic with no gem-specific API in it.

What happens to the stores that are already installed on our current app?

Nothing changes for them. Your existing SHOPIFY_API_KEY and SHOPIFY_API_SECRET stay exactly where they are and remain the fallback for every shop with no row in fleet_stores. Existing installs keep authenticating against the app they installed, and stores move to their own dedicated app as DeploTeka provisions them. There is no cutover moment and no downtime window.

Our webhooks go through shopify_app WebhooksController. Is that enough?

Almost. Overriding hmac_valid? makes the controller-level check per-shop, and that is the check that decides whether the request is rejected. But shopify_app WebhooksController then hands the request to ShopifyAPI::Webhooks::Registry.process, which validates a second time using ShopifyAPI::Context.api_secret_key — the process-global one. If you route fleet shops through the Registry you will hit that second check against the base app secret. The simplest answer is to receive fleet webhooks in your own controller that includes the verification concern and does not call the Registry.

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

Two curls and one real check. Register a fake shop and confirm 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 send a webhook signed with a dedicated app secret to a shop that has a fleet_stores row and confirm it verifies, while the same body signed with the base secret does not. That last check is the whole feature.

Is there a DeploTeka gem for this, like the Laravel package?

Not today, and the reason is structural rather than a matter of priority. The Laravel adapter exists because kyon147/laravel-shopify ships config_api_callback, a documented closure the package calls to resolve api* configuration values per shop; the adapter is an implementation of that one hook. shopify_app has no equivalent seam — credentials are read straight off a global singleton at three different call sites — so a gem would have to monkey-patch three concerns in a way that breaks on any of their refactors. A recipe you own, plus npx deploteka onboard . --scaffold to write the first draft of it, is the honest version.