Post

Deploy Medusa Commerce with Docker, Traefik, Postgres and Redis

Run Medusa as a self-hosted ecommerce stack with a backend, Next.js storefront, PostgreSQL database, Redis cache and Traefik HTTPS routing.

Deploy Medusa Commerce with Docker, Traefik, Postgres and Redis

Medusa is a modern commerce backend for building self-hosted shops, admin workflows, product catalogs, carts, orders and storefront APIs. This deployment runs Medusa as a Docker stack with a dedicated backend, a Next.js storefront, PostgreSQL for durable state, Redis for cache and event infrastructure, and Traefik for HTTPS routing.

This guide documents the pattern used for shop.example.com, with public domains shown as examples and secrets intentionally replaced with placeholders.

Do not publish real passwords, API keys, JWT secrets, cookie secrets or private infrastructure IPs in public docs. Keep them in .env files with restricted permissions and rotate anything that leaks.


What this service does

Route or access pattern:

1
2
3
https://shop.example.com/       -> Medusa storefront
https://shop.example.com/app    -> Medusa admin/dashboard
https://shop.example.com/health -> Medusa backend health check

Main components:

1
Medusa backend, Medusa admin, Next.js storefront, PostgreSQL, Redis, Docker Compose, Traefik.

Network and port model:

1
2
Traefik routes HTTPS traffic to the storefront on port 8000 and selected backend paths to port 9000.
PostgreSQL and Redis stay on the internal Docker network only.

The important production idea is separation: the browser reaches Traefik, Traefik reaches the application containers, and internal services such as Postgres and Redis are never exposed directly to the internet.


If you are building this as part of the same homelab stack, read these too:


Folder layout

Use one dedicated service folder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/home/ubuntu/Medusa/
├── docker-compose.yml
├── .env
├── app/
│   ├── Dockerfile
│   ├── apps/backend/
│   │   ├── .env
│   │   ├── medusa-config.ts
│   │   └── package.json
│   └── apps/storefront/
│       └── .env
└── data/
    ├── postgres/
    └── redis/

Keep application source under app/, persistent databases under data/, and secrets in .env files. Do not commit the .env files.


Environment files

At the stack level, keep the database identity and public host in /home/ubuntu/Medusa/.env:

MEDUSA_HOST=shop.example.com
POSTGRES_DB=medusa
POSTGRES_USER=medusa
POSTGRES_PASSWORD=<strong-database-password>

The backend app should have its own environment file, for example app/apps/backend/.env:

NODE_ENV=development
DATABASE_URL=postgres://medusa:<strong-database-password>@postgres:5432/medusa?sslmode=disable
REDIS_URL=redis://redis:6379
CACHE_REDIS_URL=redis://redis:6379
STORE_CORS=https://shop.example.com
ADMIN_CORS=https://shop.example.com
AUTH_CORS=https://shop.example.com
JWT_SECRET=<long-random-jwt-secret>
COOKIE_SECRET=<long-random-cookie-secret>

The storefront app can use a public backend URL for browser requests and an internal backend URL for server-side rendering:

NEXT_PUBLIC_MEDUSA_BACKEND_URL=https://shop.example.com
MEDUSA_BACKEND_INTERNAL_URL=http://medusa:9000
NEXT_PUBLIC_BASE_URL=https://shop.example.com

That internal backend URL matters because the storefront container can reach the Medusa backend directly over Docker networking without hairpinning through Cloudflare or Traefik.


Docker Compose stack

A practical Compose layout is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
services:
  postgres:
    image: postgres:15-alpine
    container_name: medusa-postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /home/ubuntu/Medusa/data/postgres:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 30s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    container_name: medusa-redis
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - /home/ubuntu/Medusa/data/redis:/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 30s
      timeout: 5s
      retries: 10

  medusa:
    build:
      context: ./app
    image: local/medusa:latest
    container_name: medusa-backend
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    env_file:
      - ./app/apps/backend/.env
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
      REDIS_URL: redis://redis:6379
      CACHE_REDIS_URL: redis://redis:6379
    networks:
      - internal
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.medusa-backend-secure.entrypoints=https"
      - "traefik.http.routers.medusa-backend-secure.rule=Host(`${MEDUSA_HOST}`) && (PathPrefix(`/app`) || PathPrefix(`/admin`) || PathPrefix(`/auth`) || PathPrefix(`/store`) || PathPrefix(`/api`) || PathPrefix(`/uploads`) || PathPrefix(`/health`) || PathPrefix(`/socket.io`) || PathPrefix(`/dashboard`))"
      - "traefik.http.routers.medusa-backend-secure.tls=true"
      - "traefik.http.routers.medusa-backend-secure.tls.certresolver=cloudflare"
      - "traefik.http.services.medusa-backend.loadbalancer.server.port=9000"

  storefront:
    build:
      context: ./app
    image: local/medusa:latest
    container_name: medusa-storefront
    restart: unless-stopped
    depends_on:
      medusa:
        condition: service_started
    env_file:
      - ./app/apps/storefront/.env
    environment:
      NEXT_PUBLIC_MEDUSA_BACKEND_URL: https://${MEDUSA_HOST}
      MEDUSA_BACKEND_INTERNAL_URL: http://medusa:9000
      NEXT_PUBLIC_BASE_URL: https://${MEDUSA_HOST}
    networks:
      - internal
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.medusa-storefront-secure.entrypoints=https"
      - "traefik.http.routers.medusa-storefront-secure.rule=Host(`${MEDUSA_HOST}`)"
      - "traefik.http.routers.medusa-storefront-secure.tls=true"
      - "traefik.http.routers.medusa-storefront-secure.tls.certresolver=cloudflare"
      - "traefik.http.services.medusa-storefront.loadbalancer.server.port=8000"

networks:
  internal:
  proxy:
    external: true

The backend router should have a higher priority or a more specific path rule than the storefront router. The storefront catches the root domain, while backend paths such as /app, /store, /auth, /api and /health route to Medusa on port 9000.


Redis configuration

Redis is not just a decorative container in this stack. It should be wired into Medusa explicitly.

Install the Redis cache provider in the backend package:

1
2
3
4
5
6
{
  "dependencies": {
    "@medusajs/caching": "2.15.2",
    "@medusajs/caching-redis": "2.15.2"
  }
}

Then configure app/apps/backend/medusa-config.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { loadEnv, defineConfig } from '@medusajs/framework/utils'

loadEnv(process.env.NODE_ENV || 'development', process.cwd())

module.exports = defineConfig({
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
    databaseDriverOptions: {
      ssl: false,
      sslmode: "disable",
    },
    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",
    }
  },
  modules: [
    {
      resolve: "@medusajs/medusa/caching",
      options: {
        providers: [
          {
            resolve: "@medusajs/caching-redis",
            id: "caching-redis",
            is_default: true,
            options: {
              redisUrl: process.env.CACHE_REDIS_URL,
              ttl: 3600,
              prefix: "medusa",
            },
          },
        ],
      },
    },
  ],
})

Use both variables deliberately:

  • REDIS_URL=redis://redis:6379 gives Medusa a Redis connection for framework features that expect the project Redis URL.
  • CACHE_REDIS_URL=redis://redis:6379 points the caching module at the same Redis service.
  • redis-server --appendonly yes enables append-only persistence so Redis can recover its working dataset after a container restart.
  • The Redis container stays on the internal network and is not published to the host.

Verify Redis from inside the container:

1
docker exec medusa-redis redis-cli ping

Expected result:

1
PONG

Verify the backend sees Redis by checking backend logs after startup:

1
docker logs medusa-backend --tail=200 | grep -i redis

You want to see Redis/cache initialization messages and no repeated connection failures.


Deploy

Create the service folder and start the stack:

1
2
cd /home/ubuntu/Medusa
docker compose up -d --build

Watch startup:

1
2
3
4
5
docker compose ps
docker logs medusa-postgres --tail=100
docker logs medusa-redis --tail=100
docker logs medusa-backend --tail=200
docker logs medusa-storefront --tail=200

Run the public checks:

1
2
3
curl -I https://shop.example.com/
curl -fsS https://shop.example.com/health
curl -I https://shop.example.com/app

For this example deployment, the checks passed with:

1
2
3
/      -> redirects to the storefront locale path
/health -> HTTP 200
/app    -> HTTP 200

Operations checklist

After the stack is live:

  • confirm medusa-postgres, medusa-redis, medusa-backend and medusa-storefront are running;
  • confirm Redis answers PONG;
  • confirm backend logs do not show Redis cache connection failures;
  • confirm Traefik has routers for the storefront and backend path prefixes;
  • confirm /health returns 200 through the public domain;
  • add the shop to your dashboard, such as Homepage, with a siteMonitor pointed at the public URL;
  • back up /home/ubuntu/Medusa/data/postgres and, if you rely on Redis persistence, /home/ubuntu/Medusa/data/redis too.

Backup notes

PostgreSQL is the source of truth for store data. Use database-aware backups instead of only copying the raw volume:

1
docker exec medusa-postgres pg_dump -U medusa medusa > medusa-postgres-$(date +%F).sql

Redis is primarily cache and fast state. Because this stack enables append-only persistence, include the Redis data directory in service-level backups, but treat Postgres as the critical restore target.


Troubleshooting

Backend cannot connect to Postgres:

1
2
docker logs medusa-postgres --tail=100
docker logs medusa-backend --tail=200

Check that DATABASE_URL uses the Docker service name postgres, not localhost.

Redis cache errors:

1
2
docker exec medusa-redis redis-cli ping
docker logs medusa-backend --tail=200 | grep -i redis

Check that REDIS_URL and CACHE_REDIS_URL use redis://redis:6379 and that the backend depends on the Redis health check.

Storefront cannot reach the backend:

1
docker exec medusa-storefront wget -qO- http://medusa:9000/health

If that works internally but the browser fails publicly, inspect Traefik router rules and CORS values.

Admin path loads the storefront instead of Medusa:

1
docker logs traefik --tail=200 | grep medusa

Make sure backend routes for /app, /admin, /auth, /store, /api, /uploads, /health, /socket.io and /dashboard are more specific than the storefront root router.


Final shape

The final architecture is simple and maintainable:

1
2
3
4
5
6
7
Browser
  -> Cloudflare DNS
  -> Traefik HTTPS router
  -> Medusa storefront on port 8000
  -> Medusa backend/admin/API on port 9000
  -> PostgreSQL for durable commerce data
  -> Redis for cache and fast internal state

That gives you a self-hosted ecommerce platform with a modern admin, a customizable storefront, persistent database storage, explicit Redis caching, and the same reverse-proxy pattern used by the rest of the homelab.

This post is licensed under CC BY 4.0 by the author.