Back to Blog
Tutorial7 min read2026-07-13

How to Deploy Vendure with Managed PostgreSQL

Deploy Vendure to production: build the server and worker, run TypeORM migrations safely, fix the asset storage trap, and wire up PostgreSQL via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Vendure is a headless commerce framework built on NestJS and TypeORM. It's a serious piece of software — GraphQL Shop and Admin APIs, a job queue, a full admin UI — and that shows up at deploy time. Unlike a typical single-process Express app, Vendure ships as two cooperating processes, and two of its development defaults (schema sync and local asset storage) will quietly hurt you in production if you don't change them. Here's the full path from a @vendure/create scaffold to a live shop.

What you're actually deploying

A project scaffolded with npx @vendure/create my-shop has two entry points:

  • src/index.ts — the server: serves the Shop API at /shop-api, the Admin API at /admin-api, and the Admin UI at /admin.
  • src/index-worker.ts — the worker: processes job queue tasks like sending emails, updating the search index, and applying collection filters.

Both read the same src/vendure-config.ts. They coordinate through the job queue, and the default DefaultJobQueuePlugin polls the database — so you don't need Redis to get started. One Postgres instance carries everything.

If you deploy only the server and forget the worker, the shop *appears* to work: pages load, orders go through. Then you notice search results never update and no emails go out. The jobs just pile up in the queue table. Deploy both.

Building for production

The scaffold compiles TypeScript with tsc:

npm run build

Output lands in dist/. The relevant start scripts:

npm run start:server   # node ./dist/index.js
npm run start:worker   # node ./dist/index-worker.js

The scaffold's npm start runs both concurrently, which is fine for a single-container deployment. The server reads its port from the environment — the scaffold config uses +(process.env.PORT || 3000) in apiOptions — so set PORT rather than hardcoding.

Connecting PostgreSQL via DATABASE_URL

The scaffold expects discrete DB_HOST / DB_PORT / DB_USERNAME variables, but most managed platforms hand you a single DATABASE_URL connection string. TypeORM accepts a url property directly, so the fix is small. In src/vendure-config.ts:

import path from 'path';

export const config: VendureConfig = {
  // ...
  dbConnectionOptions: {
    type: 'postgres',
    url: process.env.DATABASE_URL,
    synchronize: false,
    migrations: [path.join(__dirname, './migrations/*.+(js|ts)')],
    logging: false,
  },
  // ...
};

Make sure the pg driver is in your dependencies (it is, if you picked Postgres during scaffolding).

Migrations: turn synchronize off

synchronize: true lets TypeORM mutate your schema to match your entities on every boot. Great in development, dangerous in production — an entity refactor can silently drop a column full of order data. Set synchronize: false and use migrations.

Vendure wraps TypeORM's migration tooling. Generate a migration after schema-affecting changes (new plugins with entities, custom fields):

npx vendure migrate

The CLI walks you through generating, running, or reverting. For production, the pattern I'd recommend is running pending migrations before the server bootstraps, in src/index.ts:

import { bootstrap, runMigrations } from '@vendure/core';
import { config } from './vendure-config';

runMigrations(config)
  .then(() => bootstrap(config))
  .catch(err => {
    console.error(err);
    process.exit(1);
  });

One caveat: if you scale the server to multiple replicas, two instances booting at once can race on the same migration. Keep a single replica during a deploy that includes migrations, or run migrations as a separate one-off step before traffic shifts.

The asset storage trap

This is the gotcha that bites almost everyone. The default AssetServerPlugin writes uploaded product images to static/assets on the local filesystem. In a container, that filesystem is ephemeral — every redeploy starts from the image, and every product photo your team uploaded is gone.

Point asset storage at an S3-compatible bucket instead. The plugin ships a helper:

import { AssetServerPlugin, configureS3AssetStorage } from '@vendure/asset-server-plugin';

AssetServerPlugin.init({
  route: 'assets',
  assetUploadDir: path.join(__dirname, '../static/assets'),
  storageStrategyFactory: configureS3AssetStorage({
    bucket: 'my-shop-assets',
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID!,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
    },
    nativeS3Configuration: {
      endpoint: process.env.S3_ENDPOINT,
      region: process.env.S3_REGION,
      forcePathStyle: true,
    },
  }),
}),

This needs @aws-sdk/client-s3 installed, and works with any S3-compatible store — AWS S3, Cloudflare R2, MinIO. Do this before launch, not after the first "where did our images go" incident.

The scaffold's EmailPlugin has a similar dev-only default: it writes emails to a local mailbox folder instead of sending them. Switch it to an SMTP transport with your provider's credentials when you go live.

A Dockerfile for Vendure

FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/static ./static
EXPOSE 3000
CMD ["sh", "-c", "node ./dist/index-worker.js & exec node ./dist/index.js"]

The CMD runs the worker in the background and the server in the foreground of the same container. That's deliberately simple — the two-process split exists so you *can* scale them independently later, but for a shop that's just launching, one container running both is the right call. Note I'm not using the scaffold's npm start here, because concurrently is a devDependency and won't survive --omit=dev.

Environment variables

The scaffold reads these — set them all in production:

APP_ENV=production
PORT=3000
COOKIE_SECRET=<long random string>
SUPERADMIN_USERNAME=<admin login>
SUPERADMIN_PASSWORD=<strong password>
DATABASE_URL=<injected by your platform>

SUPERADMIN_USERNAME and SUPERADMIN_PASSWORD seed the initial admin account on first boot against an empty database. Change them from the scaffold defaults *before* the first production boot — that account is the keys to the shop.

Deploying on PandaStack

Vendure's needs map cleanly onto PandaStack:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x) from the dashboard.
  2. 2Connect your Git repo as a container app. The Dockerfile above is picked up automatically; without one, the Node buildpack is auto-detected.
  3. 3Attach the database to the app — DATABASE_URL is injected automatically, which is exactly what the url config change above expects. No copying credentials around.
  4. 4Set COOKIE_SECRET, the superadmin credentials, and your S3 variables as environment variables in the dashboard.
  5. 5Push to your branch. The build runs in a rootless BuildKit pod, you watch the build logs stream live, and the app goes live with your custom domain and automatic SSL.

One honest note: on the free tier, apps scale to zero when idle and cold-start on the next request. That's fine for evaluating Vendure or demoing a storefront to a client, but a real shop taking orders should run on a paid tier where instances stay warm on stable nodes.

The checklist

Before you call it done: synchronize: false with a real migration in place, worker process running, assets on S3-compatible storage, email transport switched off dev mode, superadmin credentials rotated, and COOKIE_SECRET set to something that didn't come from a README.

If you want the database provisioning and wiring handled for you, it takes a few minutes to try on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also