Shopify app notes · 11

Deploying a Shopify App

Getting your app onto a real server: the two deploys people confuse, running migrations without losing data, and a checklist for going live.

Docker environment variables

The two deploys

This is the single most confusing thing about shipping a Shopify app, and it causes a lot of "why isn't my change live?".

1. Your code

Route files, components, server logic, database schema.

Goes to your hosting — Docker, a VPS, Fly, Railway, wherever.

git push → build → restart

2. Your app settings

Scopes, webhook topics, app URLs, app proxy, extensions.

Goes to Shopify.

shopify app deploy

Trap — doing only one of them

Add a webhook to the TOML and deploy your code: the webhook is never registered, because Shopify never heard about it.

Run shopify app deploy but not your code deploy: Shopify starts sending webhooks to a route that does not exist yet.

Many changes need both. Ask yourself each time: did I change a file, the TOML, or both?


What the server actually runs

In development the Shopify CLI runs everything. In production it is just Node:

npm run build    # compile into build/
npm run start    # node server.js

Your server.js serves the compiled files and handles requests. No CLI, no tunnel — your real domain points at this process.

The build must happen before the start

npm run start reads from build/. If you forget the build step, or ship a stale build/ folder, your server runs the previous version and you will swear your changes vanished.


A Docker setup

Docker packages your app so the server runs exactly what you tested. The Shopify template already includes a Dockerfile — you usually adjust it rather than write it. A minimal, realistic version:

Dockerfile
FROM node:22-alpine
RUN apk add --no-cache openssl        # Prisma needs this

WORKDIR /app
ENV NODE_ENV=production
EXPOSE 3000

COPY package.json package-lock.json* ./
RUN npm ci --omit=dev                 # exact versions, no dev deps

COPY . .
RUN npm run build

CMD ["npm", "run", "docker-start"]

Where docker-start runs setup and then the server:

"setup":        "prisma generate && prisma migrate deploy",
"docker-start": "npm run setup && npm run start"

So every container start regenerates the database client and applies any pending migrations before serving traffic. Note it uses migrate deploy — the only migrate command safe near production (Note 05).

docker-compose.yml — you write this one; the template has no compose file
services:
  app:
    build: .
    restart: unless-stopped        # come back after a crash or reboot
    env_file:
      - .env
    ports:
      - '127.0.0.1:3004:3000'      # only localhost — see below
Why 127.0.0.1: matters

Writing '3004:3000' exposes your app to the whole internet on port 3004, bypassing your web server. Prefixing 127.0.0.1: means only the machine itself can reach it, and Nginx or Caddy in front handles HTTPS and forwards requests.

That front layer is what terminates SSL. Shopify requires HTTPS, and your Node process does not do it.


Environment variables

Production needs its own values. Never copy your development .env to the server.

VariableProduction value
DATABASE_URLThe production database
SHOPIFY_API_KEYFrom the production app
SHOPIFY_API_SECRETFrom the production app — secret
SHOPIFY_APP_URLYour real domain, no trailing slash
SCOPESMust match the TOML exactly
NODE_ENVproduction
Trap — the scopes mismatch

Scopes live in the TOML and in SCOPES. On your machine, shopify app dev passes the TOML's scopes to your app for you, so it always works there. Add a permission, deploy the TOML, and forget the server's SCOPES — the app breaks in production while working perfectly on your machine.

Whenever you touch scopes, fix two places: the TOML (then deploy it), and SCOPES on the server.

Restart after editing env vars

They are read at startup. Editing .env changes nothing until the process restarts (docker compose up -d --build).


Database migrations in production

The scary part, and it is manageable with two habits.

Back up first

mysqldump -u user -p dbname > backup-$(date +%F).sql

Take one before any deploy that includes a migration. It costs a minute and is the difference between an inconvenience and a disaster.

Use only migrate deploy

npx prisma migrate deploy      # applies pending migrations, nothing else

Never migrate dev (can offer to reset), never migrate reset (deletes everything), never db push (silent changes).

Trap — destructive migrations

Renaming a column is usually generated as "drop the old one, add a new one" — and all its data disappears. Same for changing a type in an incompatible way.

Read the generated SQL in prisma/migrations/ before deploying. If you see DROP COLUMN and did not intend to lose that data, do it in stages instead: add the new column, copy the data across in code, and remove the old one in a later release.

Also make new columns optional (String?) or give them a default. Adding a required column to a table with existing rows fails, because those rows have no value for it.


A deploy, start to finish

backup → pull code → build + restart → migrations run → app deploy → verify
# 1. Back up the database
mysqldump ... > backup.sql

# 2. Get the new code onto the server
git pull

# 3. Rebuild and restart (migrations run on start)
docker compose up -d --build

# 4. Watch it come up
docker compose logs -f

# 5. If the TOML changed, push settings to Shopify
shopify app config use production
shopify app info                  # confirm the right app!
shopify app deploy

# 6. Open the app in the merchant's admin and click through
Step 6 is not optional

A container that starts successfully is not proof the app works. Open it in the admin and use the feature you changed. Most production incidents are found by whoever looks first — make that you, not the merchant.


When a deploy goes wrong

Roll the code back

git checkout <previous-commit>
docker compose up -d --build

Roll the settings back

shopify app versions list
shopify app release --version=<previous>
Trap — migrations do not roll back

Reverting your code does not undo a migration. If the new version dropped a column, going back to the old code leaves the database still missing it — and the old code may now crash too.

This is why you take the backup, and why destructive migrations deserve a careful read before they ship. Code is reversible; data is not.


After it is live

  • Watch the logs for a while after deploying: docker compose logs -f.
  • Add uptime monitoring that checks a real URL every few minutes. If a storefront feature depends on your server (an app proxy, for example), monitor that endpoint, not just the homepage.
  • Automate database backups on a schedule — and test restoring one at least once. An untested backup is a guess.
  • Keep secrets out of git. If a secret is ever committed, rotate it; removing the file does not remove it from history.

Common problems

My changes are not live
  • Code change? The container was not rebuilt — --build is required.
  • TOML change? You did not run shopify app deploy.
  • .env change? The process was not restarted.
The container starts then immediately exits

Read docker compose logs. Usually a missing environment variable or a database it cannot reach — migrate deploy fails at startup and takes the process with it.

"Can't reach database server"

From inside a container, localhost means the container, not the host. Use the host's address or a service name from your compose file.

Prisma errors about missing engines

Alpine images need openssl installed, and prisma generate must run in the same environment that runs the app — which is why it is in the start script.

The app loads but Shopify says it is not installed

SHOPIFY_APP_URL or the TOML application_url does not match your real domain, or you deployed with the development config active.


Go-live checklist

  • Production app created in Shopify, with its own config file.
  • Server .env filled in — production keys, real domain, matching SCOPES.
  • HTTPS working through a reverse proxy; the app port bound to localhost only.
  • Database backups scheduled, and one restore tested.
  • A fresh backup taken immediately before the deploy.
  • Generated migration SQL read — no unintended DROP.
  • shopify app info confirms the right app before deploy.
  • Webhooks verified as arriving (shopify app logs).
  • Uptime monitoring on a URL that proves the app really works.
  • You clicked through the app in the merchant's admin afterwards.

Cheat sheet

# two deploys — most changes need BOTH
code   → git pull && docker compose up -d --build
config → shopify app deploy

# deploy order
backup → pull → build+restart → migrations → app deploy → verify in admin

# production database — only these
npx prisma migrate deploy
npx prisma generate
# never: migrate dev, migrate reset, db push

# rollback
git checkout <commit> && docker compose up -d --build
shopify app release --version=<previous>
# migrations do NOT roll back — that's what the backup is for

# scopes live in THREE places
shopify.app.toml · local .env · server .env