Docker
Production container images for the three deployable apps:
Production container images for the three deployable apps:
| App | Image tag | Port | Base runtime |
|---|---|---|---|
api | sc/api | 3000 | node:22-alpine |
admin | sc/admin | 3004 | node:22-alpine |
vendor-admin | sc/vendor-admin | 3005 | node: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 (extraprod-depsstage strips devDeps from the runtime image).apps/admin/Dockerfile,apps/vendor-admin/Dockerfile— 4-stage build, runtime is the Next.js standalone server..dockerignore— keepsnode_modules,.next,.turbo,dist,.env*,.gitout of the build context..gitea/workflows/build-push.yml— CI matrix that builds all three and pushes to the Gitea registry on push tomain.
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_URLis 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_URLare 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 redisAPI
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:devIf 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:devAdmin / 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:devNo --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-adminWebpack-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:
| Setting | Type | Value |
|---|---|---|
REGISTRY_HOST | Variable | e.g. gitea.yourdomain.com |
REGISTRY_TOKEN | Secret | Gitea access token with write:package scope |
The job uses gitea.actor as the registry username. Tags applied:
sha-<short>andsha-<long>— every build<branch>— branch namelatest— only formain<custom>— if set via the manualtaginput
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:
| Image | Size |
|---|---|
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. MeansNEXT_PUBLIC_AUTH_URLwas 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 externalizedXbased on what was installed in the builder stage, butprod-depsdoesn't have it. Either addXtostubbedModules(if dead code) or move it fromdevDependenciestodependencies.COPY .next/standalonefails.BUILD_STANDALONE=1was not picked up bynext build. Verify the var is listed inturbo.jsonglobalPassThroughEnvand that the Dockerfile sets it before theRUN turbo buildline.@sc/ui/globals.cssresolution fails inbun dev. Settingoutput: "standalone"oroutputFileTracingRootat dev time triggers this. Both are correctly gated behindBUILD_STANDALONE=1so they only apply inside the Docker builder stage.drizzle-orm/node-postgresnot found at runtime. A workspace transitive dep that wasn't installed byprod-deps. Lift the dep up toapps/api/dependenciesand re-runbun installto updatebun.lock.
Beautybarn Data Migration
How the @sc/seeder one-shot migrator moves customers, orders, rewards, reviews and wishlists from the legacy beautybarn (Prisma/Postgres) database into supercommerce, including the same-email customer merge, password preservation, money scaling and the order-status crosswalk.
Google Merchant Center — Setup
How to obtain a Google OAuth client_id / client_secret, configure them in admin settings, and pick (or create) a Merchant Center data source for the sync to target.