Medusa best practices

Performance and stability best practices for Medusa headless commerce on TurboStack - Node.js runtime, Nginx reverse proxy, PostgreSQL, Redis, process management and caching.

Medusa is a Node.js headless commerce platform that runs as one or more long-lived Node processes, with Nginx reverse-proxying public traffic to each one. Because there is no dedicated app_type, performance and stability come from how you build, run, and keep those processes running - TurboStack provides the surrounding infrastructure. This page covers what the platform configures for you and how to get the most out of it.

What TurboStack configures for you

When you deploy Medusa with the reverse-proxy pattern, the platform provides the infrastructure around your Node processes:

  • Nginx reverse proxy - for every vhost with proxy_enabled: true, Nginx creates an upstream pointing at proxy_upstream_host (default 127.0.0.1) on your proxy_upstream_port, and proxies all traffic to it. The proxy is tuned with keepalive (up to 2048 pooled connections), proxy_http_version 1.1, and WebSocket pass-through (Upgrade/Connection headers), so server-sent events and live admin updates work by default.
  • Transport Layer Security (TLS) termination - cert_type: letsencrypt issues and auto-renews certificates per domain. Nginx terminates TLS and forwards X-Forwarded-Proto, X-Forwarded-Host, and Ssl-Offloaded headers so Medusa generates correct absolute URLs.
  • PostgreSQL - the database Medusa persists all of its data in, provisioned from your postgresql_version.
  • Node.js runtime - the nodejs_version you pin per vhost installs the Node runtime each service runs on.
  • Per-vhost separation - each app_name (storefront, backend/admin) gets its own document root, Nginx vhost, access log, and upstream. Services run as independent processes on their own ports and domains.

Combine Medusa's vendor guidance with the TurboStack options below. Most caching and runtime services are one toggle away on the host's Services tab.

  • Build before you serve - always run medusa build (and build the storefront/admin) and start from the compiled output for production. Never run a dev server behind the proxy.
  • Run in production mode - start each process with NODE_ENV=production so Medusa disables dev-only behavior and the admin is served pre-built.
  • Keep processes running with a process manager - run each Node service under a supervisor (pm2, or a systemd unit). It must restart on crash and on reboot and keep listening on the configured proxy_upstream_port; if it stops, Nginx returns 502. For example, run the backend and storefront under pm2: pm2 start "npx medusa start" --name medusa-backend and pm2 start "npm start" --name storefront, then pm2 save. See Keep a Node.js app running for the full pm2 workflow.
  • Add Redis for events and cache - point Medusa's modules at a Redis instance for the event bus, workflow engine, and cache. This ensures jobs and pub/sub survive restarts and scale beyond a single process. Enable Redis from Services, then set up the cache module - see Redis cache configuration.
  • Split server and worker modes - for busy stores, run a dedicated worker process (workerMode: "worker") alongside the request-serving process (workerMode: "server") so background jobs do not block API responses.
  • Tune PostgreSQL connection pooling - size the Medusa database pool to your CPU count; avoid exhausting PostgreSQL connections across server and worker processes.
  • Serve and cache static assets via a Content Delivery Network (CDN) - place an HTTP cache/CDN in front of the storefront. This prevents the Node process from serving cacheable pages and assets on every request.
  • Optimize images and assets - pre-build and compress storefront assets; let Next.js (or your storefront framework) emit optimized, cacheable output.

Redis cache configuration

Medusa's caching module is off by default. Enable it with a feature flag, then point the cache module at the TurboStack cache Redis instance (port 6379, no password). Give each shop its own prefix and a Time to Live (TTL) so cache data stays separate and Redis does not fill up.

Add the variables to the .env file in your project (~/<project>/<shop>/):

# Enable the caching system
MEDUSA_FF_CACHING=true

# Cache Redis instance
CACHE_REDIS_URL=redis://127.0.0.1:6379

# Optional
CACHE_TTL=28800   # 8 hours in seconds
CACHE_PREFIX=example-cache:

Then enable the feature flag and register the Redis cache provider in medusa-config.ts:

import { defineConfig } from "@medusajs/framework/utils"

export default defineConfig({
  featureFlags: {
    caching: true,
  },

  modules: [
    {
      resolve: "@medusajs/medusa/caching",
      options: {
        providers: [
          {
            id: "caching-redis",
            resolve: "@medusajs/caching-redis",
            is_default: true,
            options: {
              redisUrl: process.env.CACHE_REDIS_URL,
              ttl: process.env.CACHE_TTL
                ? parseInt(process.env.CACHE_TTL, 10)
                : undefined,
              prefix: process.env.CACHE_PREFIX,
            },
          },
        ],
      },
    },
  ],
})

Medusa does not cache responses until a query opts in, so enabling the module alone does not populate Redis. A query enables caching through its cache option. Wire up a workflow that runs a cached query, then an API route that calls it.

Create a workflow at ~/<project>/<shop>/src/workflows/cache-products.ts:

import {
  createWorkflow,
  WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"

export const cacheProductsWorkflow = createWorkflow(
  "cache-products",
  () => {
    const { data: products } = useQueryGraphStep({
      entity: "product",
      fields: ["id", "title"],
      options: {
        cache: {
          enable: true,
          providers: ["caching-redis"],
        },
      },
    })

    return new WorkflowResponse(products)
  }
)

This queries the product entity and caches the response in Redis through the query's cache option.

Then expose it with an API route at ~/<project>/<shop>/src/api/cache-product/route.ts:

import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { cacheProductsWorkflow } from "../../workflows/cache-products"

export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
  const { result } = await cacheProductsWorkflow(req.scope)
    .run({})

  res.status(200).json(result)
}

This adds a /cache-product endpoint that runs the workflow and returns the cached product data.

Rebuild and restart after the change. Stop the process first, rebuild, and copy the environment file into the compiled server directory (the build produces a fresh .medusa/server that does not carry your .env):

pm2 stop <process_name>
npx medusa build
cp .env .medusa/server/.env
pm2 restart <process_name>

Click around the storefront or hit the /cache-product endpoint, then confirm keys appear under your CACHE_PREFIX in Redis.

Sizing and scaling

TurboStack auto-tunes resource limits from the host's size, so start with the defaults. Override the tuning variables only when you have measured evidence (slow queries, cache evictions, saturated CPU):

Variable Tune when
postgresql resources The database is the bottleneck under order/catalog load
redis_memory Redis is evicting keys used for events or cache

Scale Node throughput by running more processes (server + worker, or multiple server instances on different ports behind Nginx) before scaling the host. See Performance tuning before changing any tuning variable, and scale up the host when a single machine can no longer keep up.

Stability

  • Back up regularly - verify scheduled Backups cover the PostgreSQL database and any uploaded media/file storage.
  • Watch Health - monitor CPU, memory, and disk. A crash-looping Node process or a stuck migration appears here first.
  • Run migrations on every deploy - apply medusa db:migrate before starting new code so the schema matches the running version.
  • Keep versions current - track Medusa releases and keep your pinned nodejs_version and postgresql_version on supported lines.
  • Test on a staging clone - trial upgrades, plugins, and migrations on a copy before publishing to production.