Medusa is a strong open-source alternative to hosted commerce platforms, but it has more moving parts than a typical Node app. It expects PostgreSQL, Redis, and — in any serious setup — a separation between the API server and a background worker. Get those right and the rest is straightforward.
What Medusa actually needs
A production Medusa v2 deployment has these dependencies:
| Component | Role | Required? |
|---|---|---|
| PostgreSQL | Primary data store | Yes |
| Redis | Event bus, workflow engine, cache | Strongly recommended |
| Medusa server | Serves the Store + Admin APIs | Yes |
| Medusa worker | Processes subscribers/scheduled jobs | Yes for production |
The single biggest mistake is running everything in one process. Medusa supports a workerMode so the API server stays responsive while a separate process handles events, scheduled jobs, and long-running workflows.
Configure server vs worker mode
Medusa reads a MEDUSA_WORKER_MODE style switch through your config. In medusa-config.ts:
import { defineConfig, loadEnv } from '@medusajs/framework/utils';
loadEnv(process.env.NODE_ENV, process.cwd());
export default defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
workerMode: process.env.MEDUSA_WORKER_MODE as 'shared' | 'worker' | 'server',
http: {
storeCors: process.env.STORE_CORS,
adminCors: process.env.ADMIN_CORS,
authCors: process.env.AUTH_CORS,
jwtSecret: process.env.JWT_SECRET,
cookieSecret: process.env.COOKIE_SECRET
}
}
});You then deploy two services from the same image:
- Server:
MEDUSA_WORKER_MODE=server, runsmedusa start, serves HTTP. - Worker:
MEDUSA_WORKER_MODE=worker, runsmedusa start, no public HTTP.
Both share the same DATABASE_URL and REDIS_URL.
The environment variables that trip people up
CORS is the number one support question. Medusa has three separate CORS settings, and they are exact-match origin lists:
STORE_CORS=https://storefront.example.com
ADMIN_CORS=https://admin.example.com
AUTH_CORS=https://admin.example.com,https://storefront.example.comIf the admin dashboard shows network errors, it is almost always ADMIN_CORS or AUTH_CORS missing the exact origin (including scheme and no trailing slash).
Also set strong secrets — these sign sessions and tokens:
JWT_SECRET=$(openssl rand -base64 32)
COOKIE_SECRET=$(openssl rand -base64 32)Build and migrate
Medusa builds to a .medusa/server directory. The production start sequence is:
# Build
npx medusa build
# Run migrations (once, before serving)
npx medusa db:migrate
# Start
cd .medusa/server && npm install && npx medusa startRun db:migrate as a discrete step on deploy — not on every container boot, or two replicas will race each other.
A Dockerfile that works
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx medusa build
WORKDIR /app/.medusa/server
RUN npm install
EXPOSE 9000
CMD ["npx", "medusa", "start"]The same image runs as both server and worker — only MEDUSA_WORKER_MODE differs.
Deploying on PandaStack
Medusa's architecture maps cleanly onto a platform that supports multiple services plus managed data stores:
- 1Provision managed PostgreSQL and Redis from the dashboard. PandaStack injects
DATABASE_URLand a Redis URL automatically. - 2Deploy the repo as a container app (the Dockerfile is auto-detected). Set
MEDUSA_WORKER_MODE=server. This is your public API. - 3Deploy the *same* repo as a second container app with
MEDUSA_WORKER_MODE=workerand no public route. - 4Run
medusa db:migrateas a one-off before first traffic. - 5Point a custom domain at the server; SSL is automatic.
Because both services pull the same image, a single push redeploys both consistently — and deploy history gives you a clean rollback if a migration misbehaves.
Operational notes
- Admin user: create the first admin via
npx medusa user -e admin@example.com -p supersecretagainst the production DB. - File storage: configure an S3-compatible provider for product images; local disk does not survive container restarts.
- Scaling: the server scales horizontally behind a load balancer; keep exactly the right number of workers — duplicate scheduled jobs cause double-processing unless you understand Medusa's locking.
- Redis is not optional in practice: without it, the event bus and workflow engine fall back to in-memory, which breaks across multiple replicas.
Verifying the deploy
# Health of the store API
curl -s https://api.example.com/health
# Admin auth endpoint should respond (401 without creds is fine)
curl -s -o /dev/null -w '%{http_code}' https://api.example.com/auth/user/emailpassA 200 on /health plus a reachable admin login means the server, database, and Redis wiring are all correct.
References
- [Medusa production deployment docs](https://docs.medusajs.com/learn/deployment)
- [Medusa worker mode configuration](https://docs.medusajs.com/learn/production/worker-mode)
- [Medusa medusa-config reference](https://docs.medusajs.com/resources/references/medusa-config)
- [Medusa CLI reference](https://docs.medusajs.com/resources/medusa-cli)
---
Medusa's server + worker + PostgreSQL + Redis layout is exactly the kind of multi-service app PandaStack is built for, with managed databases auto-wired in. Spin one up free at [dashboard.pandastack.io](https://dashboard.pandastack.io).