Set up a Medusa v2 backend for Tender POS

About the Medusa integration

On this page

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-store

yarn 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, plus apps/storefront if 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=worker and DISABLE_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:

  1. Medusa Cloud — Medusa's own managed offering; it hosts the server, Admin dashboard, database, and Redis instance for you.
  2. 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- or shared-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 KeysCreate → 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:

  1. 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.
  2. 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>/health returns OK (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_URL is set and reachable, if this is a production deployment.
  • Either MEDUSA_WORKER_MODE=shared, or you have a separate worker-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 /admin suffix, 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.