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.
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
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.
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:
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).
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
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.
| Variable | Production value |
|---|---|
DATABASE_URL | The production database |
SHOPIFY_API_KEY | From the production app |
SHOPIFY_API_SECRET | From the production app — secret |
SHOPIFY_APP_URL | Your real domain, no trailing slash |
SCOPES | Must match the TOML exactly |
NODE_ENV | production |
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.
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).
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
# 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
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>
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 —
--buildis required. - TOML change? You did not run
shopify app deploy. .envchange? 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
.envfilled in — production keys, real domain, matchingSCOPES. - 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 infoconfirms the right app beforedeploy.- 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