3 MIN READ

Medusa Environment Variables and Configuration, Done Properly

Every variable Medusa reads, which ones are security-critical, and how to structure configuration so a missing value fails at boot instead of at checkout.

BY RAHUL MEHTAUPDATED
Illustration for “Medusa Environment Variables and Configuration, Done Properly” — Deployment

Configuration bugs do not announce themselves. A missing Stripe key does not break the build — it breaks checkout, for one customer, on a Saturday. The fix is boring and effective: validate everything at boot, so the application refuses to start rather than failing quietly later.

The variables Medusa reads

VariableRequiredPurpose
`DATABASE_URL`YesPostgres connection string
`REDIS_URL`ProductionRedis for events, cache, workflow engine
`JWT_SECRET`YesSigns authentication tokens
`COOKIE_SECRET`YesSigns session cookies
`STORE_CORS`YesOrigins allowed on the Store API
`ADMIN_CORS`YesOrigins allowed on the Admin API
`AUTH_CORS`YesOrigins allowed on auth endpoints
`MEDUSA_WORKER_MODE`Production`server`, `worker` or `shared`
`MEDUSA_BACKEND_URL`ProductionPublic backend URL, used by the admin
`PORT`NoDefaults to 9000

Everything else — payment keys, S3 credentials, search hosts — arrives as module options.

A configuration file with no literals

medusa-config.tsts
import { defineConfig, loadEnv } from "@medusajs/framework/utils"

loadEnv(process.env.NODE_ENV || "development", process.cwd())

const required = [
  "DATABASE_URL",
  "JWT_SECRET",
  "COOKIE_SECRET",
  "STORE_CORS",
  "ADMIN_CORS",
  "AUTH_CORS",
]

// Fail at boot, with a message naming what is missing, rather than at checkout.
const missing = required.filter((key) => !process.env[key])
if (missing.length) {
  throw new Error(`Missing required environment variables: ${missing.join(", ")}`)
}

module.exports = defineConfig({
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
    workerMode: (process.env.MEDUSA_WORKER_MODE ?? "shared") as never,
    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,
    },
  },
  admin: {
    // The worker does not serve the dashboard, so do not build it there.
    disable: process.env.MEDUSA_WORKER_MODE === "worker",
    backendUrl: process.env.MEDUSA_BACKEND_URL,
  },
  modules: [
    {
      resolve: "@medusajs/medusa/payment",
      options: {
        providers: [
          {
            resolve: "@medusajs/medusa/payment-stripe",
            id: "stripe",
            options: {
              apiKey: process.env.STRIPE_API_KEY,
              webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
            },
          },
        ],
      },
    },
  ],
})

The twelve lines of validation at the top are the highest-value part of the file.

Secrets

JWT_SECRET signs authentication tokens; COOKIE_SECRET signs session cookies. Anyone with either can forge a session.

bash
openssl rand -base64 32

Rules: different values per environment, never in the repository, rotated when someone with access leaves. Rotating JWT_SECRET invalidates existing tokens — every admin logs in again, which is a fine trade for a rotation you actually needed.

CORS

Three separate settings because the three surfaces have different audiences:

bash
STORE_CORS=https://shop.example.com,https://www.example.com
ADMIN_CORS=https://admin.example.com
AUTH_CORS=https://admin.example.com,https://shop.example.com

Never * in production. These APIs use credentials, and a wildcard with credentials is exactly the configuration the same-origin policy exists to prevent. Remember to include every subdomain and preview environment that legitimately calls the API.

Per-environment files

Medusa's loadEnv reads .env.<NODE_ENV> then .env:

text
.env                  # local defaults, committed only if it contains no secrets
.env.test             # test database
.env.production       # never committed
.env.template         # documents every variable, with empty values

Commit .env.template and keep it current. It is the only documentation of your configuration surface that stays honest, because a missing entry breaks someone's setup immediately.

In production, prefer your platform's secret manager over files.

Configuration in the storefront

The Next.js storefront has its own, and one of them is public:

VariablePurpose
`MEDUSA_BACKEND_URL`Server-side API calls
`NEXT_PUBLIC_MEDUSA_BACKEND_URL`Browser API calls
`NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY`Sales-channel scoping
`NEXT_PUBLIC_STRIPE_KEY`Stripe publishable key

Anything prefixed NEXT_PUBLIC_ is inlined into the JavaScript bundle. A secret with that prefix is a published secret. The publishable key is safe by design — it scopes requests to a sales channel and grants no privileged access.

Practical guidance

Fail fast and name the variable. "Missing STRIPE_API_KEY" beats "cannot read properties of undefined".

Never branch on NODE_ENV for behaviour. Use explicit flags. NODE_ENV decides optimisation, not business logic.

Log configuration at boot — keys only, never values. Knowing which providers loaded saves an hour on the first incident.

Keep the template in the same PR as the new variable. Otherwise someone's local environment breaks and nobody knows why.

A schema-validated config

The validation earlier in this post is a good floor. If configuration errors have bitten you more than once, validate shapes rather than just presence:

src/lib/env.tsts
import { z } from "zod"

const schema = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  COOKIE_SECRET: z.string().min(32),
  STORE_CORS: z.string().min(1),
  ADMIN_CORS: z.string().min(1),
  AUTH_CORS: z.string().min(1),
  MEDUSA_WORKER_MODE: z.enum(["server", "worker", "shared"]).default("shared"),
  STRIPE_API_KEY: z.string().startsWith("sk_").optional(),
})

const parsed = schema.safeParse(process.env)

if (!parsed.success) {
  console.error("Invalid environment:", parsed.error.flatten().fieldErrors)
  process.exit(1)
}

export const env = parsed.data

This catches the failures presence checks miss: a JWT secret someone set to changeme, a Stripe key that is actually a publishable key, a worker mode with a typo.

Documenting the surface

.env.templatebash
# --- Required ---
DATABASE_URL=                 # postgres://user:pass@host:5432/db
JWT_SECRET=                   # openssl rand -base64 32
COOKIE_SECRET=                # openssl rand -base64 32
STORE_CORS=                   # https://shop.example.com
ADMIN_CORS=                   # https://api.example.com
AUTH_CORS=                    # both of the above, comma separated

# --- Production ---
REDIS_URL=
MEDUSA_WORKER_MODE=           # server | worker
MEDUSA_BACKEND_URL=

# --- Payments ---
STRIPE_API_KEY=               # sk_live_… or sk_test_…
STRIPE_WEBHOOK_SECRET=        # whsec_…

Commit it, and add the entry in the same pull request as the code that reads it. A template that lags behind the code is worse than none, because people trust it.

Rotating secrets without downtime

Rotation is only painful if you have not planned it. The sequence that avoids an outage:

  1. Generate the new value and add it alongside the old where the system supports two.
  2. Deploy so every instance knows both.
  3. Switch the active value.
  4. Remove the old one in a later deploy.

For JWT_SECRET there is no dual-value support, so rotation invalidates every session. Schedule it outside business hours and tell the team, rather than discovering it when the whole operations team is logged out mid-afternoon.

Provider keys — Stripe, S3, search — usually support overlapping credentials, so those genuinely can rotate without downtime. Rotate whenever someone with access leaves, and keep a note of when each was last rotated.


Configuration review is ten minutes and prevents a specific class of outage. We include it in every audit.

Frequently asked questions

Where does Medusa load environment variables from?

`loadEnv` in `medusa-config.ts` reads `.env.<NODE_ENV>` and then `.env` from the project root. In production most teams skip files entirely and inject variables through the hosting platform's secret manager.

What is the difference between STORE_CORS, ADMIN_CORS and AUTH_CORS?

They control allowed origins for three different API surfaces: the storefront-facing Store API, the dashboard-facing Admin API, and the authentication endpoints used by both. They are separate so a storefront origin does not implicitly gain access to admin endpoints.

Is the Medusa publishable key a secret?

No. It scopes Store API requests to particular sales channels and is designed to be exposed in browser code. It grants no privileged access, which is why it is safe to ship in a `NEXT_PUBLIC_` variable.

What happens if I change JWT_SECRET?

All existing tokens become invalid, so every logged-in admin user and authenticated customer must sign in again. That is the expected cost of a rotation; schedule it rather than doing it during business hours.

How should I handle secrets in production?

Use the hosting platform's secret manager rather than files on disk, keep values distinct per environment, and never commit them. Rotate when access changes, and keep a committed `.env.template` documenting every variable with empty values.

Why is my Medusa admin dashboard not loading after deployment?

Usually `MEDUSA_BACKEND_URL` is unset or wrong, so the dashboard calls the wrong origin, or `ADMIN_CORS` does not include the domain serving it. Check the browser console for the blocked request — it names the origin the browser tried.

[ Keep reading ]