Skip to content

A Dockerfile

Beluga’s runtime needs dist/, dist-server/, db/migrations/, emails/, public/ (for the bundled demo images), package.json, and production node_modules. Paths resolve from the working directory, so the image starts from the repository root. better-sqlite3 and sharp want a toolchain in the build stage, which is why this uses the Debian-based Node image rather than Alpine.

# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/dist-server ./dist-server
COPY --from=build /app/db/migrations ./db/migrations
COPY --from=build /app/emails ./emails
COPY --from=build /app/public ./public
# Run as the unprivileged user the base image provides, and make sure the
# default data and assets locations are writable by it. A mounted volume
# replaces these paths in production.
RUN mkdir -p data public/assets && chown -R node:node /app
USER node
EXPOSE 4000
CMD ["node", "dist-server/server/index.js"]
node_modules
dist
dist-server
data
public/assets
.env
.env.*
test-results
legacy
.git

public/assets is excluded because uploads belong on a volume or in a bucket, not baked into an image. The bundled demo images live elsewhere under public/.

Terminal window
docker build -t my-store .
docker run --rm -p 4000:4000 \
-v beluga-data:/app/data -v beluga-assets:/app/public/assets \
-e SESSION_SECRET=$(openssl rand -base64 32) \
-e PUBLIC_URL=http://localhost:4000 \
my-store

The log prints the setup token. Open http://localhost:4000/setup.

With a bucket for images and Postgres for the database, drop both volumes:

Terminal window
docker run --rm -p 4000:4000 \
-e DATABASE_URL=postgres://... \
-e ASSETS_S3_BUCKET=... -e ASSETS_S3_REGION=auto \
-e ASSETS_S3_ENDPOINT=https://... \
-e ASSETS_S3_ACCESS_KEY_ID=... -e ASSETS_S3_SECRET_ACCESS_KEY=... \
-e ASSETS_PUBLIC_URL=https://images.example.com \
-e SESSION_SECRET=... -e PUBLIC_URL=https://shop.example.com \
my-store
  • npm run build before npm prune, because the build needs devDependencies and the runtime must not ship them.
  • db/migrations/ is copied. Migrations run at boot; an image without them fails to start, not just to upload.
  • emails/ is copied. Templates are read at send time.
  • One process. No supervisor, no nginx inside the container; the platform’s edge or a proxy outside handles TLS.