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
| Variable | Required | Purpose |
|---|---|---|
| `DATABASE_URL` | Yes | Postgres connection string |
| `REDIS_URL` | Production | Redis for events, cache, workflow engine |
| `JWT_SECRET` | Yes | Signs authentication tokens |
| `COOKIE_SECRET` | Yes | Signs session cookies |
| `STORE_CORS` | Yes | Origins allowed on the Store API |
| `ADMIN_CORS` | Yes | Origins allowed on the Admin API |
| `AUTH_CORS` | Yes | Origins allowed on auth endpoints |
| `MEDUSA_WORKER_MODE` | Production | `server`, `worker` or `shared` |
| `MEDUSA_BACKEND_URL` | Production | Public backend URL, used by the admin |
| `PORT` | No | Defaults to 9000 |
Everything else — payment keys, S3 credentials, search hosts — arrives as module options.
A configuration file with no literals
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.
openssl rand -base64 32Rules: 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:
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.comNever * 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:
.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 valuesCommit .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:
| Variable | Purpose |
|---|---|
| `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:
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.dataThis 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
# --- 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:
- Generate the new value and add it alongside the old where the system supports two.
- Deploy so every instance knows both.
- Switch the active value.
- 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.
Observability for Medusa: Logs, Traces and the Alerts Worth Having
Self-hosting means owning the question 'is checkout working?'. Structured logging, tracing, the four metrics that matter and alerts that do not cry wolf.
Deploying Medusa on AWS: ECS, RDS and the Parts That Bite
A production AWS architecture for Medusa — ECS Fargate, RDS, ElastiCache, S3 — plus the networking and migration details that turn a two-day job into a two-week one.
Deploying Medusa on Railway: The Fastest Production Setup
Railway is the shortest path from a Medusa repository to a production deployment that is actually correct. The full setup, including the worker service everyone forgets.


