Back to Blog
Tutorial7 min read2026-07-12

How to Deploy Medusa in Production (with PostgreSQL)

Build Medusa for production, run migrations safely, wire up PostgreSQL and Redis, and go live from a Git push — including the .medusa/server gotcha.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Medusa is a headless commerce engine built on Node.js: a store API, an admin dashboard, and a module system for payments, fulfillment, and inventory. It's a real backend — PostgreSQL-backed, migration-driven, with background workflows — which means deploying it is closer to deploying a Rails or Adonis app than dropping a static storefront on a CDN. Here's the full path from local project to production, including the parts the quickstart doesn't mention.

What Medusa needs in production

Three things, non-negotiable:

  1. 1PostgreSQL. Medusa's data layer is Postgres-only. Every entity, order, and product variant lives there.
  2. 2Redis. Locally, Medusa falls back to in-memory implementations of its event bus and workflow engine — and warns you about it. In production those fallbacks lose events on restart and can't coordinate across instances. Use Redis.
  3. 3A handful of secrets and CORS settings passed as environment variables.

If you don't have a project yet:

npx create-medusa-app@latest my-store

This scaffolds the server, the admin, and a medusa-config.ts.

The production build

Medusa compiles the server and the admin dashboard with one command:

npx medusa build

Here's the gotcha that catches almost everyone: the output goes to .medusa/server, and that directory is a standalone application with its own package.json. You don't run the built app from your project root — you install production dependencies inside the build output and start it from there:

cd .medusa/server
npm install --omit=dev
npm run start

The server listens on port 9000 by default and respects the PORT environment variable. The admin dashboard is compiled into the same build and served at /app.

If you deploy from the project root without understanding this layout, you'll ship dev dependencies, a stale build, or both.

Configuration via environment variables

medusa-config.ts should read everything from the environment. A production-ready version looks like this:

import { defineConfig, loadEnv } from '@medusajs/framework/utils'

loadEnv(process.env.NODE_ENV || 'development', 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') || 'shared',
    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!,
    },
  },
})

The variables you must set in production:

DATABASE_URL=postgres://...        # your Postgres connection string
REDIS_URL=redis://...              # your Redis connection string
JWT_SECRET=<long random string>    # signs auth tokens
COOKIE_SECRET=<long random string> # signs session cookies
STORE_CORS=https://yourstorefront.com
ADMIN_CORS=https://your-api-domain.com
AUTH_CORS=https://yourstorefront.com,https://your-api-domain.com

Generate the secrets properly — openssl rand -base64 32 — and never reuse the dev defaults. The CORS variables matter more than they look: ADMIN_CORS must include the domain serving the admin dashboard (the API's own domain, since admin is served at /app), and AUTH_CORS needs every origin that performs authentication, storefront included. Most "login works locally but not in prod" reports trace back to these three variables.

For the Redis-backed event bus and workflow engine, register the production modules:

  modules: [
    {
      resolve: '@medusajs/medusa/event-bus-redis',
      options: { redisUrl: process.env.REDIS_URL },
    },
    {
      resolve: '@medusajs/medusa/workflow-engine-redis',
      options: { redis: { url: process.env.REDIS_URL } },
    },
  ],

One warning from experience: a typo in the env var name here doesn't error — it fails silently into the in-memory fallback, and you only find out when events vanish after a restart. Check the startup logs for fallback warnings after your first deploy.

Migrations: a release step, not a boot step

Medusa manages its schema with forward-only migrations:

npx medusa db:migrate

This applies pending migrations and syncs module links. Two rules:

  • Run it before the new version takes traffic, as a release/predeploy step. Don't bake it into the app's startup command — if you scale to two instances, both will race to migrate the same database.
  • Never point it at a database you're not sure about. It's non-destructive by design, but "which environment am I in" mistakes are the ones that hurt.

You'll also need an admin user once, on first deploy:

npx medusa user -e admin@yourstore.com -p <password>

Run that as a one-off command against the production environment, then log in at https://your-api-domain.com/app.

A production Dockerfile

The two-stage build mirrors the .medusa/server layout:

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx medusa build

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.medusa/server ./
RUN npm install --omit=dev
EXPOSE 9000
CMD ["npm", "run", "start"]

The final image contains only the compiled server and production dependencies — no source, no dev toolchain.

Server and worker modes

By default Medusa runs in shared mode: one process handles HTTP requests *and* background workflows (order events, webhooks, scheduled jobs). That's fine to start. Under real load, split them with MEDUSA_WORKER_MODE:

  • One deployment with MEDUSA_WORKER_MODE=server — takes HTTP traffic.
  • One deployment with MEDUSA_WORKER_MODE=worker and DISABLE_MEDUSA_ADMIN=true — same image, same env, no traffic, just processes jobs.

Both point at the same Postgres and Redis. Start with shared; split when workflow processing starts competing with request latency.

Deploying on PandaStack

This is the workflow I use, and Medusa maps onto it cleanly because the two stateful dependencies — Postgres and Redis — are both managed services on the platform:

  1. 1Provision the databases. Create a managed PostgreSQL instance (14.x or 16.x) and a managed Redis instance from the dashboard. Backups are scheduled daily and retained per plan.
  2. 2Connect the repo as a container app. With the Dockerfile above in the repo root, the build runs in an ephemeral rootless BuildKit job and you watch the logs stream live — Medusa's build output is chatty, and seeing the admin compile in real time beats guessing why a deploy hangs.
  3. 3Attach the Postgres instance to the app. DATABASE_URL is injected automatically — no copying connection strings out of one console into another. Set REDIS_URL from your Redis instance, then add JWT_SECRET, COOKIE_SECRET, and the three CORS variables as environment variables on the app.
  4. 4Run migrations (npx medusa db:migrate) as a one-off step against the attached database, create the admin user, then push. Every subsequent git push builds and deploys; if a release goes sideways, roll back to the previous deployment from the history.

One free-tier note: apps scale to zero when idle and cold-start on the next request. For a storefront API you'll eventually want a paid tier so the process stays warm — nobody wants their checkout to be the request that pays the cold-start tax. The free tier is genuinely useful for staging, though: 1 database, 5 web services, and 300 build minutes a month cover a Medusa staging environment comfortably.

The checklist

Before you call it done:

  • [ ] npx medusa build runs clean; you're deploying .medusa/server, not the repo root
  • [ ] DATABASE_URL and REDIS_URL set; no in-memory fallback warnings in the logs
  • [ ] JWT_SECRET / COOKIE_SECRET are long, random, and not the dev values
  • [ ] STORE_CORS, ADMIN_CORS, AUTH_CORS include every real origin
  • [ ] Migrations run as a release step, not at boot
  • [ ] Admin user created; /app loads and logs in
  • [ ] A test order flows through end to end

Medusa rewards doing this properly — the module system and workflow engine are built for production scale, but only once the plumbing under them is real. If you want the plumbing to be someone else's job, connect the repo on https://pandastack.io and see how far a git push gets you.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also