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.
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
.envfiles 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.
Related documentation
If you are building this as part of the same homelab stack, read these too:
- Deploy Traefik Reverse Proxy with Docker and Cloudflare
- Deploy Cloudflare Companion for Traefik DNS Automation
- Deploy Homepage Dashboard with Docker, Traefik and Authentik
- Deploy WordPress with MySQL, Redis, Docker and Traefik
- Deploy Stalwart and Roundcube Mail Server with Docker and Cloudflare
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:6379gives Medusa a Redis connection for framework features that expect the project Redis URL.CACHE_REDIS_URL=redis://redis:6379points the caching module at the same Redis service.redis-server --appendonly yesenables append-only persistence so Redis can recover its working dataset after a container restart.- The Redis container stays on the
internalnetwork 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-backendandmedusa-storefrontare 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
/healthreturns200through the public domain; - add the shop to your dashboard, such as Homepage, with a
siteMonitorpointed at the public URL; - back up
/home/ubuntu/Medusa/data/postgresand, if you rely on Redis persistence,/home/ubuntu/Medusa/data/redistoo.
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.