stack/docker/local-emulator/Dockerfile
Devin AI f2575ee330 Add pnpm store cache mount to turbo build steps in Dockerfiles
The preinstall hook uses pnpx only-allow which needs the pnpm store.
The store is only available during RUN commands with the cache mount.

Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
2026-06-01 23:57:38 +00:00

291 lines
12 KiB
Docker

# Hexclave Local Emulator — All-in-One Image
# Packages: PostgreSQL 16, Redis 7, Inbucket, Svix, ClickHouse, MinIO, QStash
# + built Hexclave backend and dashboard
ARG NODE_VERSION=22.21.1
# ── Node.js build stages ──────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS node-base
WORKDIR /app
RUN apt-get update && \
apt-get upgrade -y && \
rm -rf /var/lib/apt/lists
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PNPM_HOME/bin:$PATH
RUN corepack enable
RUN corepack prepare pnpm@11.5.0 --activate
RUN pnpm add -g turbo
RUN pnpm add -g tsx
FROM node-base AS pruner
COPY . .
RUN tsx ./scripts/generate-sdks.ts
# https://turbo.build/repo/docs/guides/tools/docker
RUN turbo prune --scope=@hexclave/backend --scope=@hexclave/dashboard --docker
FROM node-base AS builder
# copy over package.json files and install dependencies
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
COPY .gitignore .
COPY pnpm-workspace.yaml .
COPY turbo.json .
COPY configs ./configs
COPY --from=pruner /app/scripts/postinstall-patch-next-async-debug-info.mjs ./scripts/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store STACK_SKIP_TEMPLATE_GENERATION=true pnpm install --frozen-lockfile
# copy over the rest of the code for the build
COPY --from=pruner /app/out/full/ .
# docs are currently required for the NextJS backend build, but won't exist in the final image
COPY docs ./docs
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV NEXT_CONFIG_OUTPUT=standalone
ENV NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=pk_test_mock_publishable_key_for_local_emulator
# Build the backend NextJS app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm turbo run docker-build --filter=@hexclave/backend... --filter=@hexclave/dashboard...
# Build the self-host seed script.
# tsdown -> rolldown is multi-threaded Rust; under qemu-user (cross-arch
# arm64-on-amd64) its futex emulation occasionally deadlocks and the build
# hangs forever. Bound each attempt and retry to ride out the race.
RUN cd apps/backend && \
attempt=1; \
while :; do \
timeout --kill-after=30s 600s pnpm build-self-host-migration-script && break; \
rc=$?; \
if [ "$attempt" -ge 3 ]; then \
echo "build-self-host-migration-script failed after $attempt attempts (last rc=$rc)" >&2; \
exit "$rc"; \
fi; \
echo "build-self-host-migration-script attempt $attempt failed (rc=$rc); retrying..." >&2; \
attempt=$((attempt + 1)); \
done
# Prune node_modules for runtime: remove dev tools, heavy UI packages,
# duplicate framework copies, and native binaries not needed by the
# migration script or server at runtime.
FROM builder AS migration-pruner
RUN cp -a /app/node_modules /pruned-node_modules && \
cd /pruned-node_modules/.pnpm && \
rm -rf \
# Dev tools (never needed at runtime)
typescript@* eslint@* eslint-*@* @typescript-eslint+*@* \
prettier@* vitest@* jsdom@* turbo@* turbo-*@* \
tsdown@* @changesets+*@* codebuff@* \
@testing-library+*@* vite@* vite-*@* @vitejs+*@* \
# Heavy UI packages (already traced into Next.js standalone bundles)
monaco-editor@* \
three@* three-globe@* globe.gl@* react-globe*@* \
react-icons@* lucide-react@* @phosphor-icons+*@* \
# Large optional packages not needed by migration script
posthog-js@* \
@prisma+studio-core@* @prisma+dev@* @prisma+query-plan-executor@* \
convex@* @electric-sql+*@* \
next@14* @next+swc-*@14* \
# Native build binaries not needed at runtime
@esbuild+*@* esbuild@* @rolldown+*@* \
# Duplicate date-fns versions (keep v4 only)
date-fns@2* date-fns@3*
# ── Freestyle mock build ─────────────────────────────────────────────────────
FROM node-base AS freestyle-mock-builder
WORKDIR /freestyle-mock
COPY docker/dependencies/freestyle-mock/Dockerfile /tmp/freestyle-mock-dockerfile
# Extract the inline package.json and server.mjs from the Dockerfile's RUN cat commands,
# then install dependencies. This avoids duplicating the source.
RUN node -e " \
const fs = require('fs'); \
const df = fs.readFileSync('/tmp/freestyle-mock-dockerfile', 'utf8'); \
const pkgMatch = df.match(/cat <<'EOF' > package\\.json\\n([\\s\\S]*?)\\nEOF/); \
fs.writeFileSync('package.json', pkgMatch[1]); \
const srvMatch = df.match(/cat <<'EOF' > server\\.mjs\\n([\\s\\S]*?)\\nEOF/); \
let server = srvMatch[1]; \
server = server.replace( \
'from \"fs/promises\"', \
'from \"fs/promises\"; import { symlinkSync } from \"fs\"' \
); \
server = server.replace( \
'await mkdir(workDir, { recursive: true });', \
'await mkdir(workDir, { recursive: true }); try { symlinkSync(\"/app/freestyle-mock/node_modules\", join(workDir, \"node_modules\")); } catch {}' \
); \
fs.writeFileSync('server.mjs', server); \
"
RUN npm install
# ── Mock OAuth server build ───────────────────────────────────────────────────
FROM node-base AS mock-oauth-builder
WORKDIR /mock-oauth
COPY apps/mock-oauth-server/package.json .
RUN pnpm install && pnpm add esbuild --save-dev
COPY apps/mock-oauth-server/src ./src
RUN npx esbuild src/index.ts --bundle --platform=node --target=node22 --outfile=dist/index.cjs
# ── Service binary stages ─────────────────────────────────────────────────────
FROM stripe/stripe-mock:v0.195.0 AS stripe-mock-bin
FROM inbucket/inbucket:3.1.0 AS inbucket-bin
FROM svix/svix-server:v1.88.0 AS svix-bin
FROM clickhouse/clickhouse-server:25.10 AS clickhouse-bin
FROM minio/minio:RELEASE.2025-09-07T16-13-09Z AS minio-bin
FROM minio/mc:RELEASE.2025-02-21T16-00-46Z AS mc-bin
FROM bgodil/qstash:latest AS qstash-bin
RUN cp $(which qstash) /qstash-binary 2>/dev/null || \
cp $(find / -name 'qstash' -type f -executable 2>/dev/null | head -1) /qstash-binary || \
{ echo "ERROR: qstash binary not found" >&2; exit 1; }
# ── Strip / compress service binaries (parallel stages) ──────────────────────
FROM debian:trixie-slim AS upx-compress
RUN apt-get update && apt-get install -y --no-install-recommends upx-ucl binutils && \
rm -rf /var/lib/apt/lists/*
COPY --from=clickhouse-bin /usr/bin/clickhouse /out/clickhouse
COPY --from=svix-bin /usr/local/bin/svix-server /out/svix-server
COPY --from=minio-bin /usr/bin/minio /out/minio
COPY --from=mc-bin /usr/bin/mc /out/mc
COPY --from=qstash-bin /qstash-binary /out/qstash
RUN chmod u+w /out/* && \
# Intentionally NOT stripping /out/clickhouse. The clickhouse binary is a
# self-extracting compressed executable (a small loader with a ZSTD
# payload appended after the section table); strip rewrites the ELF and
# can invalidate the loader's "find my payload" lookup, causing the
# decompressor to spin on garbage with zero log output — the exact
# symptom seen on cross-arch TCG runs. Savings from stripping would be
# only the tiny bootstrap anyway since the payload isn't in any section.
strip --strip-all /out/minio /out/svix-server /out/mc /out/qstash && \
upx -9 /out/minio /out/svix-server /out/mc /out/qstash
# ── Final image ───────────────────────────────────────────────────────────────
FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gnupg2 \
lsb-release \
curl \
ca-certificates \
&& echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
postgresql-16 \
postgresql-client-16 \
redis-server \
supervisor \
gosu \
procps \
libssl3 \
openssl \
socat \
&& apt-get purge -y --auto-remove gnupg2 lsb-release \
&& rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man /usr/share/i18n
# Node.js runtime (binary only — app bundles include all JS dependencies)
COPY --from=node-base /usr/local/bin/node /usr/local/bin/node
# Inbucket
COPY --from=inbucket-bin /opt/inbucket /opt/inbucket
# Stripe mock
COPY --from=stripe-mock-bin /bin/stripe-mock /usr/local/bin/stripe-mock
# Svix (UPX-compressed)
COPY --from=upx-compress /out/svix-server /usr/local/bin/svix-server
# ClickHouse (stripped only)
COPY --from=upx-compress /out/clickhouse /usr/bin/clickhouse
RUN ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-server && \
ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-client
# MinIO (UPX-compressed)
COPY --from=upx-compress /out/minio /usr/local/bin/minio
COPY --from=upx-compress /out/mc /usr/local/bin/mc
# QStash (UPX-compressed)
COPY --from=upx-compress --chmod=755 /out/qstash /usr/local/bin/qstash
# App
WORKDIR /app
COPY --from=builder /app/apps/backend/.next/standalone ./
COPY --from=builder /app/apps/backend/.next/static ./apps/backend/.next/static
COPY --from=builder /app/apps/backend/prisma ./apps/backend/prisma
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
COPY --from=builder /app/apps/backend/node_modules ./apps/backend/node_modules
COPY --from=builder /app/apps/dashboard/.next/standalone ./
COPY --from=builder /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
COPY --from=builder /app/apps/dashboard/public ./apps/dashboard/public
# Save the standalone-traced node_modules (runtime deps only) before the full
# migration-pruner copy overwrites it. The slim-docker-image step in the QEMU
# build restores this after migrations are baked in.
RUN cp -a /app/node_modules /app/node_modules.standalone 2>/dev/null || mkdir -p /app/node_modules.standalone
COPY --from=migration-pruner /pruned-node_modules ./node_modules
COPY --from=builder /app/packages ./packages
# Mock OAuth server (bundled single file)
COPY --from=mock-oauth-builder /mock-oauth/dist/index.cjs /app/mock-oauth-server/index.cjs
# Freestyle mock (JS execution for email rendering)
COPY --from=freestyle-mock-builder /freestyle-mock /app/freestyle-mock
COPY --from=node-base /usr/local/bin/npm /usr/local/bin/npm
COPY --from=node-base /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN mkdir -p \
/data/postgres \
/data/redis \
/data/clickhouse \
/data/clickhouse/access \
/data/clickhouse/tmp \
/data/clickhouse/user_files \
/data/clickhouse/format_schemas \
/data/minio \
/data/inbucket \
/var/log/supervisor \
/var/log/clickhouse \
/etc/clickhouse-server \
&& chown -R postgres:postgres /data/postgres
COPY docker/local-emulator/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/local-emulator/run-cron-jobs.sh /run-cron-jobs.sh
COPY docker/local-emulator/entrypoint.sh /entrypoint.sh
COPY docker/local-emulator/init-services.sh /init-services.sh
COPY docker/local-emulator/start-app.sh /start-app.sh
COPY docker/local-emulator/rotate-secrets.sh /usr/local/bin/rotate-secrets
COPY docker/local-emulator/clickhouse-config.xml /etc/clickhouse-server/config.xml
COPY docker/local-emulator/clickhouse-users.xml /etc/clickhouse-server/users.xml
COPY docker/server/entrypoint.sh /app-entrypoint.sh
RUN chmod +x /entrypoint.sh /init-services.sh /start-app.sh /app-entrypoint.sh /run-cron-jobs.sh /usr/local/bin/rotate-secrets
# PostgreSQL: 5432, Redis: 6379, Inbucket: 2500/9001/1100,
# Svix: 8071, ClickHouse: 8123/9009, MinIO: 9090, QStash: 8080
# Backend: 8102, Dashboard: 8101, Mock OAuth: 8114
EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102 8114
ENTRYPOINT ["/entrypoint.sh"]