Supercommerce API Docs
Guides & Operations

Docker

Production container images for the three deployable apps:

Production container images for the three deployable apps:

AppImage tagPortBase runtime
apisc/api3000node:22-alpine
adminsc/admin3004node:22-alpine
vendor-adminsc/vendor-admin3005node:22-alpine

All three follow a multi-stage build: pruner → deps → builder → (prod-deps) → runner. The pruner uses turbo prune --docker to extract only the workspace subset each app actually depends on, so changes outside an app's dependency closure don't invalidate its cached install layer.

Files

  • apps/api/Dockerfile — 5-stage build (extra prod-deps stage strips devDeps from the runtime image).
  • apps/admin/Dockerfile, apps/vendor-admin/Dockerfile — 4-stage build, runtime is the Next.js standalone server.
  • .dockerignore — keeps node_modules, .next, .turbo, dist, .env*, .git out of the build context.
  • .gitea/workflows/build-push.yml — CI matrix that builds all three and pushes to the Gitea registry on push to main.

Building locally

The build context is the repo root. Each command below is run from app/.

API

DOCKER_BUILDKIT=1 docker build -f apps/api/Dockerfile -t sc/api:dev .

Admin / vendor-admin

Both apps use Next.js. NEXT_PUBLIC_* values are inlined into the client bundle at build time, so they have to be passed as --build-arg. Run-time -e/--env-file cannot change them. BACKEND_URL is also build-time: it's the destination of the /bff → API proxy in next.config.js, and Next evaluates rewrites() during next build and bakes the result into routes-manifest.json (the standalone server never re-reads it), so it must be passed as a --build-arg too — a runtime -e BACKEND_URL has no effect.

DOCKER_BUILDKIT=1 docker build \
  -f apps/admin/Dockerfile \
  --build-arg NEXT_PUBLIC_API_URL=http://localhost:3004/bff \
  --build-arg NEXT_PUBLIC_AUTH_URL=http://localhost:3004/bff/auth \
  --build-arg NEXT_PUBLIC_ASSETS_URL=https://pub-ebf8609a0c8a4277afc48fb826ff4ad3.r2.dev \
  --build-arg BACKEND_URL=http://localhost:3000 \
  -t sc/admin:dev .

DOCKER_BUILDKIT=1 docker build \
  -f apps/vendor-admin/Dockerfile \
  --build-arg NEXT_PUBLIC_API_URL=http://localhost:3005/bff \
  --build-arg NEXT_PUBLIC_AUTH_URL=http://localhost:3005/bff/auth \
  --build-arg NEXT_PUBLIC_ASSETS_URL=https://pub-ebf8609a0c8a4277afc48fb826ff4ad3.r2.dev \
  --build-arg BACKEND_URL=http://localhost:3000 \
  -t sc/vendor-admin:dev .

The two URL families play different roles and both are baked at build time:

  • BACKEND_URL is the server-side proxy target — where the app's own origin forwards /bff/*. Point it at the API (internal URL in a deployment).
  • NEXT_PUBLIC_API_URL / NEXT_PUBLIC_AUTH_URL are the client-side URLs the browser calls. They must point at each app's own public origin + /bff (e.g. https://admin.example.com/bff), not the API host — that is what scopes better-auth's session cookie to each app's domain and stops admin/vendor/store from sharing one session on the common API host.

For deployed images, swap localhost for the real hosts. Different envs need different image builds — that is the trade-off of these being build-time.

BUILD_STANDALONE flag

The Next.js apps' next.config.js only enables output: "standalone" and outputFileTracingRoot when BUILD_STANDALONE=1 is set. The Dockerfiles set this in the builder stage; local bun dev does not. This avoids breaking dev-time CSS resolution of @sc/ui/globals.css.

BUILD_STANDALONE is listed in turbo.json globalPassThroughEnv so Turbo forwards it into the underlying next build invocation.

Running locally

Bring up the infra services first:

docker compose up -d postgres redis

API

The apps/api/.env references localhost for postgres / redis / S3. Use OrbStack's --network host so the container shares the host's network namespace and those references resolve as-is:

docker run --rm -it --name sc-api \
  --network host \
  --env-file apps/api/.env \
  sc/api:dev

If your Docker runtime does not support --network host on Mac (Docker Desktop), use host.docker.internal instead:

docker run --rm -it --name sc-api \
  -p 3000:3000 \
  --add-host=host.docker.internal:host-gateway \
  --env-file apps/api/.env \
  -e DATABASE_URL='postgresql://postgres:postgres@host.docker.internal:5432/supercommerce' \
  -e REDIS_HOST=host.docker.internal \
  -e S3_ENDPOINT='http://host.docker.internal:9000' \
  sc/api:dev

Admin / vendor-admin

docker run --rm -it --name sc-admin        --network host sc/admin:dev
docker run --rm -it --name sc-vendor-admin --network host sc/vendor-admin:dev

No --env-file or -e BACKEND_URL needed at run time — the /bff proxy target and all client URLs were baked into the image at build time (see the --build-arg BACKEND_URL=... above). To change where the proxy points, rebuild with a different BACKEND_URL build-arg.

Verify

curl -fsS http://localhost:3000/         # api
curl -fsS http://localhost:3004/ -o /dev/null -w '%{http_code}\n'   # admin
curl -fsS http://localhost:3005/ -o /dev/null -w '%{http_code}\n'   # vendor-admin

Webpack-bundled API quirks

apps/api/webpack.config.js bundles all @sc/* workspace packages into a single dist/main.js and externalizes everything else. This keeps the runtime image small (no need to ship workspace source), but two things have to be managed:

stubbedModules (resolve.alias = false)

Some transitive deps require() packages that we never execute on the Fastify boot path (e.g. tailwindcss from @scalar/nestjs-api-reference, express from @thallesp/nestjs-better-auth). These are aliased to false so webpack inlines them as empty objects at build time. To stub a new one, add it to the stubbedModules array in webpack.config.js.

optionalModules (IgnorePlugin)

Modules that might be installed but we don't want to fail the build if they aren't (Apollo, GraphQL, @fastify/static, etc.). Listed in the IgnorePlugin optionalModules array — ignored only when require.resolve would fail.

Runtime deps that are also build deps

If a workspace transitive dep is needed at runtime (e.g. drizzle-orm via @sc/db) but only listed under apps/api/devDependencies, the production install in the Docker prod-deps stage will skip it. Move it to dependencies on apps/api/package.json. After moving, run bun install once locally so bun.lock updates — otherwise the Docker build's --frozen-lockfile will fail.

CI: build & push

.gitea/workflows/build-push.yml runs on push to main (or manual dispatch with an optional tag override). Matrix builds all three images in parallel and pushes to the Gitea registry.

Required Gitea repo settings:

SettingTypeValue
REGISTRY_HOSTVariablee.g. gitea.yourdomain.com
REGISTRY_TOKENSecretGitea access token with write:package scope

The job uses gitea.actor as the registry username. Tags applied:

  • sha-<short> and sha-<long> — every build
  • <branch> — branch name
  • latest — only for main
  • <custom> — if set via the manual tag input

Layer caching is done via a registry-cache tag (<image>:buildcache), shared across runs per app.

Image sizes (target)

After a clean build, expect roughly:

ImageSize
sc/api~250–350 MB (bundled main.js + production node_modules)
sc/admin~150–200 MB (Next standalone + node:22-alpine)
sc/vendor-admin~150–200 MB

Check with docker images | grep '^sc/'.

Common gotchas

  • Browser hits localhost:3004/api/auth/... instead of the API. Means NEXT_PUBLIC_AUTH_URL was empty at build time and the auth-client fell back to its same-origin default. Re-build with the correct --build-arg.
  • Cannot find module 'X' at boot. Webpack externalized X based on what was installed in the builder stage, but prod-deps doesn't have it. Either add X to stubbedModules (if dead code) or move it from devDependencies to dependencies.
  • COPY .next/standalone fails. BUILD_STANDALONE=1 was not picked up by next build. Verify the var is listed in turbo.json globalPassThroughEnv and that the Dockerfile sets it before the RUN turbo build line.
  • @sc/ui/globals.css resolution fails in bun dev. Setting output: "standalone" or outputFileTracingRoot at dev time triggers this. Both are correctly gated behind BUILD_STANDALONE=1 so they only apply inside the Docker builder stage.
  • drizzle-orm/node-postgres not found at runtime. A workspace transitive dep that wasn't installed by prod-deps. Lift the dep up to apps/api/dependencies and re-run bun install to update bun.lock.

On this page