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
# ---- 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
node_modules
.medusa
.next
.git
.env*
!.env.template
*.logCopying 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
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.
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.disablewhenMEDUSA_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
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD wget -qO- http://localhost:9000/health || exit 1The 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_modulesinto the runtime stage. - Your
.dockerignoreis 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.
RUN --mount=type=cache,target=/root/.yarn/berry/cache \
yarn install --immutableOn 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:
| Symptom | Cause |
|---|---|
| Exits immediately, no logs | Missing env var; the config throws before the logger exists |
| `Cannot find module` at first request | Root `node_modules` copied instead of `.medusa/server` ones |
| `ECONNREFUSED` to Postgres | Wrong host — `localhost` inside a container is the container |
| Killed during build | Memory; the admin build needs ~2GB |
| Healthcheck failing, then restarting forever | `start-period` too short for module loading |
| Permission denied writing files | Non-root user without ownership of the copied files |
Run the image locally with the production environment before trusting it in production:
docker run --rm -it --env-file .env.production -p 9000:9000 your-image shA 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:
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.
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.
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 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.



