Skip to content

Adding New Apps to ERP-Unlocked Monorepo

This guide provides comprehensive instructions for adding new applications to the ERP-Unlocked monorepo (apps/<name>/), including workspace conventions, Docker builds, and CI/CD deployment wiring.

  1. Supported App Types
  2. Node.js Server Applications
  3. Astro.js Applications
  4. GitHub Actions & Cloud Build Configuration
  5. Deployment Considerations
Type Use Case Example Port Access
Node.js Server APIs, background workers apps/ts-temporal-worker 3000+ Internal/Public
Astro.js (v7) Static sites, SSR apps apps/webapp, apps/internal 4321+ Public/Internal
Cloudflare Worker Edge HTTP/MCP handlers apps/ops-mcp, apps/email-worker 8787 Public/Protected

Python services in this monorepo are Temporal workers (apps/temporal-worker) or Dagster assets (apps/dagster) running on Python 3.11, not standalone HTTP services — follow those apps rather than standing up a new web framework.

apps/my-node-service/
├── src/
│ ├── index.ts # Main entry point
│ ├── app.ts # App setup
│ ├── routes/
│ ├── middleware/
│ ├── services/
│ └── utils/
├── tests/
├── Dockerfile
├── package.json
├── tsconfig.json
├── .env.example
└── README.md
  1. Create the service directory under apps/:

    Terminal window
    mkdir -p apps/my-node-service/src
    cd apps/my-node-service
  2. Initialize package in the pnpm workspace (Node 22.22.0 per .nvmrc, pnpm 10.33.4):

    Create apps/my-node-service/package.json with a @erp-unlocked/<name> or @repo/<name> workspace package name and workspace:* for internal dependencies:

    {
    "name": "@erp-unlocked/my-node-service",
    "version": "0.1.0",
    "private": true,
    "type": "module",
    "dependencies": {
    "@repo/db": "workspace:*",
    "@repo/observability": "workspace:*",
    "@repo/utils": "workspace:*"
    }
    }

    Then add dependencies using pnpm:

    Terminal window
    pnpm --filter @erp-unlocked/my-node-service add express cors helmet morgan dotenv
    pnpm --filter @erp-unlocked/my-node-service add -D typescript @types/node @types/express @types/cors tsx vitest
  3. Create TypeScript configuration:

    {
    "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules", "dist", "tests"]
    }
  4. Create the main application:

    src/app.ts
    import express from 'express';
    import cors from 'cors';
    import helmet from 'helmet';
    import morgan from 'morgan';
    const app = express();
    // Middleware
    app.use(helmet());
    app.use(cors());
    app.use(morgan('combined'));
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));
    // Health check endpoint
    app.get('/health', (req, res) => {
    res.json({ status: 'healthy', service: 'my-node-service' });
    });
    // Root endpoint
    app.get('/', (req, res) => {
    res.json({ message: 'My Node.js Service' });
    });
    export default app;
    src/index.ts
    import app from './app';
    import dotenv from 'dotenv';
    dotenv.config();
    const PORT = process.env.PORT || 3000;
    app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
    });
  5. Create Dockerfile using the shared node-builder base image:

    Monorepo Node/Astro Dockerfiles extend the pre-warmed node-builder image (images/node-builder/Dockerfile, Node 22.22.0 + pnpm 10.33.4) and keep workspace package.json COPY blocks synced via scripts/generate-dockerfile-copies.sh (enforced in CI by pnpm check:dockerfile-copies). See Build Pipeline for full details.

    ARG NODE_BUILDER_IMAGE=us-east4-docker.pkg.dev/ordermatic-prod/erp-unlocked/node-builder:latest
    FROM ${NODE_BUILDER_IMAGE} AS builder
    WORKDIR /app
    # Generated workspace package.json copies (update via: pnpm generate:dockerfile-copies)
    # BEGIN GENERATED PACKAGE COPIES
    # ...
    # END GENERATED PACKAGE COPIES
    RUN pnpm install --frozen-lockfile
    COPY packages/ ./packages/
    COPY apps/my-node-service/ ./apps/my-node-service/
    RUN pnpm --filter @erp-unlocked/my-node-service build
    FROM node:22.22.0-alpine AS production
    WORKDIR /app
    COPY --from=builder /app/apps/my-node-service/dist ./dist
    COPY --from=builder /app/node_modules ./node_modules
    EXPOSE 3000
    CMD ["node", "dist/index.js"]

    After adding a new workspace package.json, regenerate the Dockerfile copy blocks across the repo:

    Terminal window
    pnpm generate:dockerfile-copies
  6. Update package.json scripts:

    {
    "scripts": {
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "test": "vitest run",
    "lint": "eslint src/**/*.ts",
    "typecheck": "tsc --noEmit"
    }
    }
apps/my-astro-app/
├── src/
│ ├── pages/
│ ├── components/
│ ├── layouts/
│ └── content/
├── public/
├── dist/
├── Dockerfile
├── astro.config.mjs
├── package.json
├── tsconfig.json
└── README.md
  1. Create the Astro v7 application under apps/:

    Terminal window
    mkdir -p apps/my-astro-app
    cd apps/my-astro-app
    pnpm create astro@latest .
  2. Configure Astro for production:

    astro.config.mjs
    import { defineConfig } from 'astro/config';
    export default defineConfig({
    output: 'server', // or 'static' for static sites like apps/docs
    build: {
    assets: 'assets',
    },
    server: {
    port: 4321,
    host: true,
    },
    });
  3. Create Dockerfile:

    Follow apps/webapp/Dockerfile or apps/internal/Dockerfile, using images/node-builder/Dockerfile (node:22.22.0-alpine with pnpm@10.33.4), running pnpm generate:dockerfile-copies, and exposing /api/health for Cloud Run readiness probes. See Build Pipeline for the canonical multi-stage Cloud Build pattern.

See Build Pipeline for the complete runbook on adding a new service:

  1. Workspace package.json copies: Run pnpm generate:dockerfile-copies so images/node-builder/Dockerfile, apps/webapp/Dockerfile, apps/internal/Dockerfile, and apps/ts-temporal-worker/Dockerfile include the new app’s package.json.
  2. GCP Cloud Run / GCE VM services:
    • Add change detection in .github/workflows/gcp-deploy.yml
    • Add docker-build-<app> and deploy-<app> steps in cloudbuild.yaml (remembering to keep serviceAccount: cloud-build-runner@ordermatic-prod... and logging: CLOUD_LOGGING_ONLY on any cloudbuild*.yaml)
  3. Cloudflare Workers:
    • Add a wrangler.toml in apps/<worker>/ and create .github/workflows/deploy-<worker>.yml (modeled on .github/workflows/deploy-ops-mcp.yml or .github/workflows/deploy-email-worker.yml).

CI runs on Node 22.22.0 (.nvmrc), pnpm 10.33.4, and Python 3.11 (uv sync --frozen). Turborepo automatically picks up build, lint, test, and typecheck scripts defined in apps/<name>/package.json.

# Example CI setup matching .github/workflows/validate.yml:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc' # 22.22.0
cache: 'pnpm'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

Pick the target first — it decides everything else:

If the app is… Deploy it as Wired up in
An Astro/Node HTTP service GCP Cloud Run a docker-build-* + deploy-* step pair in cloudbuild.yaml, plus a Cloud Run module in infrastructure-gcp/terraform/environments/production/
A long-running worker (Temporal, Dagster) systemd unit on an existing GCE VM an ansible playbook in infrastructure-gcp/ansible/playbooks/ + a deploy-* step in cloudbuild.yaml
An edge/HTTP handler with no heavy deps Cloudflare Worker a wrangler.toml in the app plus its own .github/workflows/deploy-<app>.yml
A static site Cloudflare Pages its own workflow (copy deploy-docs.yml) + a proxied CNAME in infrastructure/terraform/environments/production/main.tf

Whatever the target:

  1. Secrets go in Infisical under /runtime/<app> (and /build/<app> if the build needs them) — never in the repo, never in the workflow file. Add the path to infrastructure/infisical/secrets-map.json, which validate-secrets-map.yml enforces.
  2. Health check — expose /health (or /api/health) and point the platform’s readiness probe at it.
  3. Changesets — add the app to apps/<name>/ in the pnpm workspace so pnpm changeset can version it.
  4. Internal-only? Put it behind Cloudflare Access with a module "access_<app>" block (see access_internal for the pattern), and make the DNS record proxied — Access cannot intercept a grey-cloud record.

Cloud Run services are private by default (allow_unauthenticated = false). Service-to-service calls carry a Google ID token; a caller needs run.invoker on the callee. The team.ordermatic.co edge VM is the worked example — its service account holds run.invoker on erp-internal-prod and mints an ID token per request.

For anything cross-service and asynchronous, prefer a Temporal workflow over direct HTTP: it gives you retries, timeouts, and visibility for free.

  • Health check endpoint implemented
  • Proper error handling and logging
  • Environment variables for configuration
  • Dockerfile follows multi-stage build pattern and pnpm check:dockerfile-copies passes
  • Tests cover core functionality
  • CI/CD pipeline updated (cloudbuild.yaml / .github/workflows/gcp-deploy.yml or .github/workflows/deploy-<worker>.yml)
  • Documentation in README.md
  • Security considerations addressed
  • Python 3.11+ and uv for dependency management
  • Virtual environment in Docker
  • OpenTelemetry for observability
  • Pydantic for data validation
  • Async/await where appropriate
  • Node 22.22.0 and TypeScript for type safety
  • pnpm 10 (workspace:* for internal packages)
  • ESLint and Prettier configured
  • Security middleware (helmet, cors)
  • Non-root user in Docker
  • Astro v7 with Static vs SSR decision made
  • Build optimization configured
  • SEO considerations addressed
  • Performance optimizations applied
  1. Service can’t communicate with others:

    • Check Cloud Run IAM (run.invoker) or local Docker Compose network configuration
    • Verify service names and ports
  2. Build failures:

    • Run pnpm check:dockerfile-copies to ensure workspace package.json copy blocks are in sync
    • Remember Cloud Build’s classic builder does not enable BuildKit (COPY --chmod= and RUN --mount= will fail)
    • See Build Pipeline
  3. Deployment failures:

    • Read the Cloud Build step log (not just the green GitHub Actions run)
    • Verify the image tag exists in Artifact Registry
    • Check the Infisical path resolves for that environment
Terminal window
# Check local service health
curl http://localhost:4321/api/health
# View local Docker Compose service logs
docker compose logs -f webapp
# Check running containers
docker compose ps

Following this guide ensures consistent, secure, and maintainable service additions to the ERP-Unlocked monorepo. Each service type has specific requirements, but they all follow the same deployment pipeline and internal networking patterns.

Remember to:

  1. Run pnpm generate:dockerfile-copies after adding apps/<name>/package.json
  2. Wire the deploy target into cloudbuild.yaml + .github/workflows/gcp-deploy.yml (or .github/workflows/deploy-<worker>.yml)
  3. Set up proper monitoring
  4. Update documentation
  5. Test service communication

For questions or additional service types, refer to the Build Pipeline runbook and existing implementations.