3 MIN READ

Dockerising Medusa: A Production Image That Is Not 2GB

A multi-stage Dockerfile for Medusa v2, a compose file for local development, and the build-memory problem that catches every team once.

BY RAHUL MEHTAUPDATED
Illustration for “Dockerising Medusa: A Production Image That Is Not 2GB” — Docker

Medusa containerises without drama, with two caveats that account for nearly every problem people hit: the admin build needs real memory, and the same image has to run as both server and worker.

The Dockerfile

Dockerfiledockerfile
# ---- deps -------------------------------------------------------------
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable

# ---- build ------------------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Builds the backend and the admin dashboard into .medusa/server
RUN yarn build

# The build output is its own package with its own dependency tree.
WORKDIR /app/.medusa/server
RUN yarn install --production

# ---- runtime ----------------------------------------------------------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup -S medusa && adduser -S medusa -G medusa

COPY --from=build --chown=medusa:medusa /app/.medusa/server ./

USER medusa
EXPOSE 9000

CMD ["npx", "medusa", "start"]

The step people miss is the second yarn install inside .medusa/server. The build output is a self-contained application with its own package.json; copying the root node_modules gives you a container that starts and then cannot resolve a module at the first request.

.dockerignore

text
node_modules
.medusa
.next
.git
.env*
!.env.template
*.log

Copying a local node_modules into the build context is the most common cause of a slow build and a broken native binding.

Compose for local development

docker-compose.ymlyaml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: medusa
      POSTGRES_PASSWORD: medusa
      POSTGRES_DB: medusa
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U medusa"]
      interval: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  server:
    build: .
    environment:
      DATABASE_URL: postgres://medusa:medusa@postgres:5432/medusa
      REDIS_URL: redis://redis:6379
      MEDUSA_WORKER_MODE: server
      JWT_SECRET: ${JWT_SECRET}
      COOKIE_SECRET: ${COOKIE_SECRET}
      STORE_CORS: http://localhost:8000
      ADMIN_CORS: http://localhost:9000
      AUTH_CORS: http://localhost:8000,http://localhost:9000
    ports: ["9000:9000"]
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_started }

  worker:
    build: .
    environment:
      DATABASE_URL: postgres://medusa:medusa@postgres:5432/medusa
      REDIS_URL: redis://redis:6379
      MEDUSA_WORKER_MODE: worker
      JWT_SECRET: ${JWT_SECRET}
      COOKIE_SECRET: ${COOKIE_SECRET}
    depends_on:
      postgres: { condition: service_healthy }

volumes:
  pgdata:

Same image, two services, different MEDUSA_WORKER_MODE. This mirrors production, which is the point — a development environment that cannot reproduce a duplicate-job bug is not worth much. See deploying to production for why the split matters.

Migrations

Do not run them in CMD. Multiple replicas starting together will race.

yaml
  migrate:
    build: .
    command: ["npx", "medusa", "db:migrate"]
    environment:
      DATABASE_URL: postgres://medusa:medusa@postgres:5432/medusa
    depends_on:
      postgres: { condition: service_healthy }

In production this becomes a pre-deploy job — an ECS run-task, a Kubernetes Job, a Railway pre-deploy command. It must complete before new containers take traffic. Migration deployment patterns.

The build memory problem

medusa build compiles the admin dashboard, a full React application. On a 512MB build runner it fails, usually with an unhelpful exit code or a killed process.

  • Give the build at least 2GB.
  • On CI, use a larger runner for the build step specifically.
  • On the worker, set admin.disable when MEDUSA_WORKER_MODE === "worker" — the worker never serves the dashboard, so building it there is pure waste. See configuration.

The runtime container is far smaller. This is a build-time constraint only.

Health checks

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
  CMD wget -qO- http://localhost:9000/health || exit 1

The start-period matters. Medusa loads modules and verifies the database on boot; an aggressive check kills the container mid-startup and produces a crash loop that looks like an application bug.

Image size

The multi-stage build above lands around 300–400MB on Alpine. If yours is over a gigabyte:

  • You copied the root node_modules into the runtime stage.
  • Your .dockerignore is missing or incomplete.
  • You are running a single-stage build with dev dependencies.

Watch for native dependencies on Alpine — sharp and similar need musl builds. If a package misbehaves, node:22-slim on Debian is a fine trade of 50MB for an afternoon.

Layer caching that actually works

The dependency stage exists so a code change does not reinstall node_modules. That only holds if you copy the manifests before the source — which is why the Dockerfile above has a separate deps stage.

Two more things that keep builds fast:

Order layers from least to most volatile. System packages, then manifests, then source. Source changes every commit; package.json does not.

Use BuildKit cache mounts for the package manager.

dockerfile
RUN --mount=type=cache,target=/root/.yarn/berry/cache \
    yarn install --immutable

On CI with a persistent cache this turns a two-minute install into a ten-second one.

Debugging a container that will not start

In order of likelihood:

SymptomCause
Exits immediately, no logsMissing env var; the config throws before the logger exists
`Cannot find module` at first requestRoot `node_modules` copied instead of `.medusa/server` ones
`ECONNREFUSED` to PostgresWrong host — `localhost` inside a container is the container
Killed during buildMemory; the admin build needs ~2GB
Healthcheck failing, then restarting forever`start-period` too short for module loading
Permission denied writing filesNon-root user without ownership of the copied files

Run the image locally with the production environment before trusting it in production:

bash
docker run --rm -it --env-file .env.production -p 9000:9000 your-image sh

A shell in the actual image answers questions that reading the Dockerfile does not.

Multi-architecture images

If your team develops on Apple Silicon and deploys to x86, build for both or you will hit exec format error on the first deploy:

bash
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/medusa:latest \
  --push .

Cross-platform builds are slower, so most teams build linux/amd64 in CI and let developers build natively for local work. Whichever you choose, be explicit — an image that runs on the developer's laptop and not in production is a confusing hour.

Native dependencies are where this bites hardest. Anything with a compiled binding needs the right binary per architecture, which is another argument for keeping the runtime dependency tree small.


We containerise and deploy Medusa as part of most engagements. Ask for the setup.

Frequently asked questions

What is the correct Dockerfile for Medusa v2?

A multi-stage build: install dependencies, run `medusa build`, then run `yarn install --production` inside the resulting `.medusa/server` directory and copy only that into a slim runtime stage. The final image runs `npx medusa start` from that directory as a non-root user.

Why does my Medusa Docker build fail or get killed?

Almost always memory. `medusa build` also builds the admin dashboard, which needs around 2GB. Increase the build container's memory limit, or use a larger CI runner for that step.

Should the server and worker be separate containers?

Yes. Run the same image twice with `MEDUSA_WORKER_MODE=server` and `MEDUSA_WORKER_MODE=worker`. Without the split, every web replica also runs scheduled jobs, so a job fires once per replica.

Where should migrations run in a containerised deployment?

In a separate one-off task that completes before new containers take traffic — a compose service, an ECS run-task, or a Kubernetes Job. Running them in the container's start command causes concurrent replicas to race.

Why does my container start and then fail on the first request?

Usually because the runtime stage has the root `node_modules` rather than the ones installed inside `.medusa/server`. The build output is a self-contained application with its own dependency tree and needs its own production install.

Can I run Medusa on Kubernetes?

Yes — two deployments from one image, differing by worker mode, with a Job for migrations and managed Postgres and Redis. It is a good fit at scale, and usually more machinery than a single-store deployment needs.

[ Keep reading ]