Guides · March 6, 2023 · 9 min read
Deploying a Medusa + Next.js Stack with Kamal on a VPS
Kamal turns a plain VPS into a Docker-based, zero-downtime deploy target for a Medusa backend and a Next.js storefront — no Kubernetes, no PaaS lock-in. Here is the exact setup, from Dockerfiles to deploy.yml to rollback.
By Polo Themes
You can put a production Medusa backend and a Next.js storefront on a single ordinary VPS using Kamal, Basecamp's Docker-based deploy tool, without touching Kubernetes or a managed PaaS. The short version: containerize each app, point Kamal at your server's SSH connection and a container registry, and let kamal deploy build, push, and swap containers with health checks and zero-downtime cutover baked in.
Kamal was built by 37signals to deploy Basecamp and Hey to their own hardware, and it generalizes well to any Dockerized web app — including a headless commerce stack. For a Medusa + Next.js pair, that means: one deploy.yml per app (or one file describing two services), one Traefik-style kamal-proxy handling TLS and zero-downtime traffic cutover, and one command — kamal deploy — that builds, pushes, and swaps containers with health checks in between. This guide walks through the whole path: server prep, Dockerfiles for both apps, the Kamal config, secrets and accessories (Postgres, Redis), and the rollback story if something goes wrong.
Why Kamal for a Medusa + Next.js stack
Medusa is a Node.js commerce engine with a Postgres database and (in most setups) a Redis instance for the event bus and caching. Next.js, running as the storefront, is a second Node process that talks to Medusa's HTTP API. That is two long-running services plus two stateful accessories — a shape that is awkward for pure static hosting but does not need a full container orchestrator either. Kamal sits in the gap: it assumes you already have Docker images, and its job is purely to get containers onto one or more servers, keep them running, and swap versions without downtime.
The alternative most teams reach for first is a managed platform — Railway, Render, or a PaaS with a Node buildpack. Those are fine choices, and often the right one when you want zero infrastructure ownership. Kamal is the right choice when you want to own a plain VPS (a $20-$40/month box from Hetzner, DigitalOcean, or similar) for cost or control reasons, and you are comfortable owning backups, monitoring, and OS patching yourself in exchange. It is not a Heroku replacement in features — there is no autoscaling, no managed database — but for a small-to-mid commerce stack running on one to three boxes, it removes almost all of the operational pain of "deploy a container to a server" while staying fully inspectable: every step is a Docker command you could run by hand.
Architecture for this deploy
The target layout for this guide is a single VPS running four things: the Medusa backend container, the Next.js storefront container, a Postgres container (or a managed database if you prefer not to run your own), and a Redis container. Kamal's kamal-proxy runs alongside them, handling TLS termination via Let's Encrypt and routing requests to whichever container version is currently live. If you outgrow one box, Kamal supports multiple destination servers and role-based deploys (web vs. worker) from the same config, so this setup scales out without a rewrite.
- medusa-backend: the Medusa API and admin, built from your apps/backend Dockerfile, listening internally on port 9000
- storefront: the Next.js app, built from apps/storefront, listening internally on port 3000
- postgres and redis: run as Kamal accessories (or replaced with managed equivalents)
- kamal-proxy: installed automatically by Kamal on first deploy, terminates TLS and load-balances between old/new containers during a rollout
Prerequisites
Before writing any config, get these in place. You need a VPS running a recent Ubuntu or Debian with SSH key access and Docker installable (Kamal installs Docker for you if it is missing, but having it pre-installed avoids surprises). You need a container registry — Docker Hub, GitHub Container Registry, or a self-hosted one — since Kamal builds locally (or in CI) and pushes an image rather than building on the server. You need a domain with DNS you control, pointed at the VPS's IP, for both the API and storefront hostnames. And you need Ruby available locally or in CI to install the kamal gem, since Kamal itself is a Ruby CLI even though it deploys any language's containers.
- A VPS with at least 2 vCPU / 4GB RAM for a small store (Postgres and Redis are memory-hungry under load)
- Docker installed locally (for building images) and SSH access to the server
- A container registry account (Docker Hub or GHCR both work well with Kamal out of the box)
- Two DNS records: api.yourstore.com for Medusa, and yourstore.com or shop.yourstore.com for the Next.js storefront
- gem install kamal on your workstation or CI runner
Step 1: Dockerize the Medusa backend
Medusa v2 runs as a standard Node.js app once built, so the Dockerfile is a straightforward multi-stage build: install dependencies, run the Medusa build (which compiles the admin dashboard and server code), then copy only the built output plus production dependencies into a slim runtime image. Keep the build stage separate from the runtime stage so your final image does not carry devDependencies or the full monorepo — that keeps image size and deploy time down, which matters directly for how fast Kamal can cut over traffic.
Two Medusa-specific details matter here. First, run database migrations as a release step, not inside the container's normal startup — Kamal supports pre-deploy hooks for exactly this, so you run medusa db:migrate once against the target database before the new containers take traffic, rather than racing multiple container replicas trying to migrate simultaneously. Second, make sure NODE_ENV=production and your Medusa admin CORS, store CORS, and auth CORS environment variables are set to the real public hostnames before first deploy — a very common first-deploy failure is CORS rejecting the Next.js storefront because these were left as localhost defaults.
Step 2: Dockerize the Next.js storefront
Next.js has first-class support for standalone output, which is what you want for a lean container. Setting output: "standalone" in next.config produces a minimal server bundle with only the traced dependencies it actually needs, which you copy into a slim final image alongside the .next/static and public folders. This standalone server is what actually runs in production — it is not the same dev server you use locally, so test the built output with node server.js before trusting a deploy.
The detail that trips people up here is environment variables and build time versus runtime. Any NEXT_PUBLIC_* variable — like your Medusa API URL or publishable API key — gets baked into the client bundle at build time, not read at container start. That means Kamal needs those variables available during the Docker build step (as build args), not just as runtime environment variables on the container. If you set the Medusa URL only as a runtime env var and expect the client bundle to pick it up, it will not — the value gets inlined wherever your code references process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL during the build.
Step 3: Write deploy.yml
Kamal's config lives in config/deploy.yml, generated by kamal init. For a two-app stack you have two clean options: run kamal init in each app's directory with its own deploy.yml (simplest to reason about, each app deploys independently), or use Kamal's multi-service support inside one config with a servers block per role. For most teams starting out, two independent configs — one for the backend, one for the storefront — is easier to operate and debug, since a storefront-only content change does not need to touch the backend's deploy pipeline at all.
Each deploy.yml needs: the image name and registry, the server's IP or hostname under servers, the exposed port your app listens on, the proxy host (your public domain) for kamal-proxy to route and issue a Let's Encrypt certificate for, and your registry credentials. Kamal reads secrets from a .kamal/secrets file locally (never committed) or from your CI's secret store, and interpolates them into the generated container's environment — this is where your Medusa database URL, JWT and cookie secrets, and Next.js publishable key live.
Step 4: Accessories — Postgres and Redis
Kamal has a first-class accessories concept for exactly this: long-running supporting services that are not your main app but need to live alongside it, with their own image, port, volume, and env vars. Declaring Postgres and Redis as accessories gets you persistent named volumes (so data survives redeploys) and a stable internal hostname the Medusa container can reach them at. This is the right call for a single-VPS setup; if you later want managed Postgres for its own backup and failover story, you simply drop the accessory block and point DATABASE_URL at the managed instance instead — nothing else about the Kamal config changes.
Whichever you choose, back up the database on a schedule independent of your deploy process. Kamal does not manage backups — a small cron job running pg_dump to off-server storage, or your managed provider's automated backups, is a separate and necessary piece.
Step 5: Deploy, zero-downtime, and rollback
A first deploy is kamal setup, which provisions Docker on the server, boots kamal-proxy, and brings up your accessories and app for the first time. Every deploy after that is kamal deploy: it builds the image, pushes it to your registry, pulls it on the server, starts the new container, waits for it to pass its health check, then flips kamal-proxy's routing to the new container and stops the old one. Because both old and new containers briefly run side by side, real users see zero dropped requests during the cutover — this is Kamal's core value proposition and the reason it beats a naive "stop old container, start new one" script.
Health checks matter more here than they might elsewhere: Kamal will not cut traffic to a container that never reports healthy, which is exactly what you want, but it means your Medusa backend needs a real health endpoint that only returns success once migrations and startup are actually complete — not just "process is listening." If a deploy goes wrong anyway, kamal rollback re-points the proxy at the previous image, which is still present on the server, giving you a near-instant recovery path without a rebuild.
Monitoring and logs after deploy
Kamal ships kamal app logs and kamal app exec for quick log tailing and one-off commands against the live container, which covers a surprising amount of day-to-day debugging without needing a separate observability stack. For anything beyond that — error tracking, uptime alerting, structured log aggregation — plan to add a lightweight APM or error-tracking service to both the Medusa backend and the Next.js storefront; Kamal's job ends at "the right container is running and reachable," not application-level observability.
If you are still assembling the storefront itself, it is worth browsing well-structured UI foundations rather than building every screen from a blank canvas — our Figma UI kits are a reasonable starting point for the storefront's design system before you wire it up to a headless Medusa backend, and our blog has more on the design and commerce side of this stack as it develops.
Frequently Asked Questions
Do I need Kubernetes for a production Medusa deployment?
No. Kubernetes earns its complexity at a scale most commerce stores never reach — many teams and clusters, dozens of services, autoscaling under highly variable load. For one to a few servers running Medusa and a Next.js storefront, Kamal gets you Docker-based, zero-downtime deploys with a fraction of the operational surface area.
Can Kamal deploy the Medusa backend and Next.js storefront to different servers?
Yes. Kamal's servers list accepts multiple hosts and supports per-role placement, so you can run the backend, storefront, and accessories on separate boxes while still deploying each with a single command. Many teams start on one VPS and split the storefront onto its own server later without changing the overall deploy model.
What happens to in-flight requests during a Kamal deploy?
kamal-proxy keeps the previous container running and receiving traffic until the new one passes its health check, then switches routing over. In-flight requests to the old container are allowed to finish before it is stopped, which is what makes the cutover effectively zero-downtime rather than a hard cut.
Should I run Postgres as a Kamal accessory or use a managed database?
Running Postgres as an accessory is fine for early-stage stores and keeps everything on one bill and one server. Once uptime and backup guarantees matter more than the cost savings, switching to a managed Postgres provider is a one-line change to your DATABASE_URL secret — the rest of the Kamal setup is unaffected.