Audience: a developer standing up a new Medusa v2 backend — or reviewing one you've inherited — that will connect to Tender POS. This guide gets a real Medusa backend running and production-ready. For the Tender POS-specific pieces once it's up, see Connect Medusa to Tender POS (what your merchant admin does) and the webhook subscriber guide (the code you deploy for near-real-time sync).
Tender POS's own adapter and webhook subscriber are written and verified against Medusa 2.13.6. The steps below — project scaffolding, config shape, deployment topology — reflect Medusa's currently published v2 docs and general framework behavior, and aren't tied to that same exact pin. Where this page and your installed version disagree, trust what's actually installed.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20 or later | @medusajs/medusa and @medusajs/framework 2.13.6 both declare "engines": { "node": ">=20" } — verified directly against the installed packages. Medusa's install docs describe this as "LTS versions only," and separately cap the optional Next.js Starter Storefront at Node v24 LTS or lower. |
| Git | any current release | Required by the project generator. |
| PostgreSQL | no published minimum version | Medusa's v2 install docs say only that PostgreSQL must be "installed and running" — there's no minimum version number published for v2. Use a current, supported release from your host. Don't reuse an old "9.6+" figure you might see in v1-era discussions; it doesn't apply here. |
| Redis | not required in development; required in production | Medusa v2 defaults to an in-memory event bus and workflow engine, both explicitly documented as fine for development and testing and explicitly not recommended for production. See "Production topology" below. |
Source: https://docs.medusajs.com/learn/installation
Creating a project
npx create-medusa-app@latest my-medusa-storeyarn dlx and pnpm dlx work the same way — Medusa's own docs note yarn
and pnpm as faster than npm here. This single command:
- asks whether to install the Next.js Starter Storefront
- scaffolds a backend (in a monorepo layout —
apps/backend, plusapps/storefrontif you opted in) - creates a PostgreSQL database automatically, named
medusa-[project-name] - runs migrations and seed data
- opens the admin dashboard in your browser so you can create the first admin user
Source: https://docs.medusajs.com/learn/installation
create-medusa-app also takes documented flags, useful for a non-interactive
or CI setup:
| Flag | Purpose |
|---|---|
--repo-url <url> |
Repository to scaffold from (defaults to Medusa's own DTC starter) |
--no-browser |
Don't open the browser at the end |
--skip-db |
Skip DB creation, migrations, and seeding |
--db-url <url> |
Use an existing database connection URL |
--no-migrations |
Skip migrations, admin-user creation, and seeding |
--directory-path <path> |
Parent directory for the new project |
--with-nextjs-starter |
Install the Next.js Starter Storefront |
--verbose |
Detailed logs |
--plugin |
Scaffold a Medusa plugin project instead of an app |
--version <version> |
Pin a specific Medusa version |
--use-npm / --use-yarn / --use-pnpm |
Force a package manager |
Source: https://docs.medusajs.com/resources/create-medusa-app
Once scaffolded, run the backend with npm run dev from its directory.
Default local endpoints:
- Server:
http://localhost:9000 - Admin dashboard:
http://localhost:9000/app - Storefront (if installed):
http://localhost:8000
Source: https://docs.medusajs.com/learn/installation
medusa-config.ts
Medusa v2 configures itself from a single config file — medusa-config.ts
in Medusa's current docs and scaffolding, though a project can equally use
medusa-config.js (same shape, different extension; both work). Current
documented shape:
import { loadEnv, defineConfig } from "@medusajs/framework/utils"
loadEnv(process.env.NODE_ENV || "development", process.cwd())
module.exports = defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
http: {
storeCors: process.env.STORE_CORS,
adminCors: process.env.ADMIN_CORS,
authCors: process.env.AUTH_CORS,
jwtSecret: process.env.JWT_SECRET || "supersecret",
cookieSecret: process.env.COOKIE_SECRET || "supersecret",
jwtExpiresIn: "1d",
compression: {
enabled: true,
level: 6,
},
authMethodsPerActor: {
user: ["emailpass"],
customer: ["emailpass", "google"],
},
},
redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
workerMode: "shared",
},
admin: {
disable: false,
path: "/app",
backendUrl: process.env.MEDUSA_BACKEND_URL || "http://localhost:9000",
storefrontUrl: process.env.MEDUSA_STOREFRONT_URL || "http://localhost:8000",
},
modules: [
{
resolve: "./src/modules/custom-module",
options: { apiKey: process.env.API_KEY },
},
],
plugins: [
"plugin-package-name",
{
resolve: "another-plugin",
options: { setting: "value" },
},
],
})Source: https://docs.medusajs.com/learn/configurations/medusa-config
Nothing here needs to change for Tender POS. The webhook subscriber files
are plain src/subscribers/*.ts files — Medusa auto-loads every file in
that directory, with no entry required in modules, plugins, or
anywhere else in this config.
Running it and creating an admin user
create-medusa-app normally creates your first admin user for you,
interactively, as part of scaffolding. To create one manually — a fresh
environment, a CI seed step, or a second admin — use the Medusa CLI:
npx medusa user --email admin@medusajs.com --password supersecret| Flag | Meaning |
|---|---|
-e, --email |
Required. |
-p, --password |
Optional — omit to be prompted for it interactively. |
-i |
Explicit user ID. |
--invite |
Create an invite token instead of an active user. The invitee accepts it at /app/invite?token=<invite_token>, or via the Accept Invite API route. |
Source: https://docs.medusajs.com/resources/medusa-cli/commands/user
The admin dashboard is served at <backend-url>/app by default
(admin.path in the config above) — locally, http://localhost:9000/app;
on a deployed backend, <your-backend-url>/app, unless you've set
admin.disable: true.
Source: https://docs.medusajs.com/learn/fundamentals/admin, https://docs.medusajs.com/learn/installation
Production topology: server and worker mode
MEDUSA_WORKER_MODE (projectConfig.workerMode in medusa-config.ts)
takes one of three values:
| Mode | What it does |
|---|---|
shared |
Default. One process does everything — fine for development and small deployments. |
server |
Handles incoming API requests and serves the Admin dashboard. Nothing else. |
worker |
Processes background jobs, scheduled jobs, and subscribers. |
Source: https://docs.medusajs.com/learn/deployment/general
This matters directly for Tender POS. The
webhook subscriber
you'll eventually install is a set of src/subscribers/*.ts files, and
subscribers only execute in a worker- or shared-mode process. A
single shared-mode deployment already satisfies this — most small
installs never need to think about it further. If you split into separate
server/worker instances, a normal pattern once traffic grows, the
subscriber files and their POS_WEBHOOK_* environment variables need to
live on the worker-mode instance. Put them only on the server-mode
instance and they will never run — with nothing telling you so; the
server-mode instance boots cleanly and serves the Admin API exactly as
expected, it just never executes anything in src/subscribers/.
Both modes need the database and Redis connection, plus their own secrets:
- Server mode:
COOKIE_SECRET,JWT_SECRET,MEDUSA_WORKER_MODE=server,DISABLE_MEDUSA_ADMIN=false,DATABASE_URL,REDIS_URL,PORT,MEDUSA_BACKEND_URL,ADMIN_CORS,AUTH_CORS,STORE_CORS. - Worker mode: the same database/Redis connection and secrets, plus
MEDUSA_WORKER_MODE=workerandDISABLE_MEDUSA_ADMIN=true. No CORS variables needed — a worker-mode instance serves no browser-facing routes at all.
A server-mode instance exposes a health check at <backend-url>/health,
returning OK.
Source: https://docs.medusajs.com/learn/deployment/general
Redis matters beyond the worker/server split, too. Medusa's default Local
Event Module (an in-memory, Node EventEmitter) and In-Memory Workflow
Engine Module are explicitly documented as suitable for development and
testing, and explicitly not recommended for production. The general
deployment guide instead recommends the Redis-backed Event Bus, Workflow
Engine, Caching, and Locking modules — all configured from the same
REDIS_URL.
Source: https://docs.medusajs.com/resources/infrastructure-modules/event/local, https://docs.medusajs.com/resources/infrastructure-modules/workflow-engine/in-memory, https://docs.medusajs.com/learn/deployment/general
Deployment
Medusa documents exactly two deployment paths itself:
- Medusa Cloud — Medusa's own managed offering; it hosts the server, Admin dashboard, database, and Redis instance for you.
- A generic self-hosting guide — platform-agnostic, describing the requirements below rather than walking through one vendor's console.
Source: https://docs.medusajs.com/learn/deployment, https://docs.medusajs.com/resources/deployment, https://docs.medusajs.com/learn/deployment/general
There's no current, official Medusa v2 guide for Railway, AWS, DigitalOcean, or Docker specifically. Community boilerplates and guides fill that gap in practice — they can work well, but they aren't Medusa's own documented path. Treat any such guide, a Railway-based one included, as community-maintained rather than vendor-endorsed, and expect to adapt it as Medusa's own framework changes.
Whichever host you pick, it needs to provide:
- A reachable PostgreSQL database (
DATABASE_URL) - A reachable Redis instance in production (
REDIS_URL) - A public HTTPS origin for the server-mode instance — this doubles as the
"Medusa API URL" you'll eventually give Tender POS, and as where an
admin reaches
/app - The server-mode and worker-mode environment variables from the Production topology section above, set on the correct instance for each
- An actually-running
worker- orshared-mode process, so background jobs, scheduled jobs, and — once installed — the Tender POS subscriber execute at all
Creating the secret API key for Tender POS
Medusa v2 has two kinds of API keys, and they are not interchangeable:
- Publishable key — for unauthenticated Store API calls from a storefront. Safe to ship in client-side code. Cannot reach the Admin API at all.
- Secret key — for Admin API access, acting as whichever admin user created it. This is the one Tender POS needs.
Source: https://docs.medusajs.com/resources/commerce-modules/api-key/concepts
To create one: Medusa Admin → Settings → API Key Management → Secret Keys → Create → give it a title (e.g. "Tender POS") → Save → copy the key from the one-time popup. It is not shown again — if you lose it, revoke it and create a new one instead of hunting for it.
Secret keys are conventionally shown as sk_... in Medusa's own examples.
That's a strong convention, not a documented guarantee — don't write code
that pattern-matches on the prefix.
There's no scoped or restricted grant for these keys the way Shopify's OAuth scopes work: a secret key is all-or-nothing for whatever the creating admin user can do. If you want to hand Tender POS a narrower-privileged credential, create a dedicated admin user for it first and scope that user's own role down as far as your Medusa setup allows, then create the secret key from that user.
Authenticate with HTTP Basic, not Bearer. Medusa's own Admin API
reference is explicit that sending a secret key as
Authorization: Bearer <secret_api_key> returns a 401. Use:
Authorization: Basic <base64(secret_api_key + ":")>
— the secret key as the username, an empty password, standard HTTP Basic auth (RFC 7617). This is the exact form Tender POS's own client sends, and it's been verified working against a live Medusa 2.13.6 instance.
Source: https://docs.medusajs.com/api/admin
CORS
medusa-config.ts (see above) reads three CORS variables, each gating
browser access to one route prefix:
| Variable | Gates |
|---|---|
STORE_CORS |
Browser origins allowed to call /store/* |
ADMIN_CORS |
Browser origins allowed to call /admin/* |
AUTH_CORS |
Browser origins allowed to call /auth/* — recommended as the union of the other two, since /auth serves both actor types |
Source: https://docs.medusajs.com/v2/advanced-development/api-routes/cors
The framework itself defaults each of these to an empty string when
unset — there's no built-in "allow localhost" default inside Medusa. Any
http://localhost:... values you see in a freshly scaffolded project's
.env come from what create-medusa-app writes out for local
development, not from a framework default. In production, set real
origins explicitly; don't assume anything is allowed until you do.
None of this affects Tender POS. CORS is a browser-enforced
restriction: a browser reads the Access-Control-Allow-Origin response
header and decides whether to expose a response to page script. It is not
a server-side firewall. Tender POS calls your Admin API server-to-server —
no browser involved, no preflight request — so it is never blocked by
ADMIN_CORS, regardless of what it's set to. Configure these three
variables for your own admin dashboard's and storefront's actual browser
origins; they have nothing to do with whether Tender POS can reach you.
Hand off
Once your backend is running and you have a secret API key:
- Install the Tender POS webhook subscriber — the code that streams product, inventory, order, payment, fulfillment, and customer changes to Tender POS in near-real-time.
- Walk your merchant admin (or yourself, if that's you too) through Connect Medusa to Tender POS: the Medusa API URL, the secret API key, and a webhook signing secret you generate together.
Verification checklist
Run through this before handing the connection details to Tender POS:
-
curl <your-backend-url>/healthreturnsOK(server-mode instance). - You can log into the Admin dashboard at
<your-backend-url>/app. - A secret API key exists under Settings → API Key Management → Secret Keys.
- That key works server-to-server against your stock locations — see the check below.
-
REDIS_URLis set and reachable, if this is a production deployment. - Either
MEDUSA_WORKER_MODE=shared, or you have a separateworker-mode instance actually running, before you install the webhook subscriber. - The URL you'll hand to Tender POS as the "Medusa API URL" is the
bare origin only — no
/adminsuffix, no trailing path — and reachable over public HTTPS.
This is the same check Tender POS runs at connect time, against your stock locations:
curl -H "Authorization: Basic $(printf '%s:' "$SECRET_KEY" | base64)" \
"<your-backend-url>/admin/stock-locations"A 200 with your stock locations means you're ready. A 401 means the
key, or the Basic-auth encoding, is wrong.
