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.
Table of Contents
Section titled “Table of Contents”- Supported App Types
- Node.js Server Applications
- Astro.js Applications
- GitHub Actions & Cloud Build Configuration
- Deployment Considerations
Supported App Types
Section titled “Supported App Types”| 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.
Node.js Server Applications
Section titled “Node.js Server Applications”Directory Structure
Section titled “Directory Structure”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.mdSetup Steps
Section titled “Setup Steps”-
Create the service directory under
apps/:Terminal window mkdir -p apps/my-node-service/srccd apps/my-node-service -
Initialize package in the
pnpmworkspace (Node22.22.0per.nvmrc,pnpm10.33.4):Create
apps/my-node-service/package.jsonwith a@erp-unlocked/<name>or@repo/<name>workspace package name andworkspace:*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 dotenvpnpm --filter @erp-unlocked/my-node-service add -D typescript @types/node @types/express @types/cors tsx vitest -
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"]} -
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();// Middlewareapp.use(helmet());app.use(cors());app.use(morgan('combined'));app.use(express.json());app.use(express.urlencoded({ extended: true }));// Health check endpointapp.get('/health', (req, res) => {res.json({ status: 'healthy', service: 'my-node-service' });});// Root endpointapp.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}`);}); -
Create Dockerfile using the shared
node-builderbase image:Monorepo Node/Astro Dockerfiles extend the pre-warmed
node-builderimage (images/node-builder/Dockerfile, Node22.22.0+ pnpm10.33.4) and keep workspacepackage.jsonCOPYblocks synced viascripts/generate-dockerfile-copies.sh(enforced in CI bypnpm check:dockerfile-copies). See Build Pipeline for full details.ARG NODE_BUILDER_IMAGE=us-east4-docker.pkg.dev/ordermatic-prod/erp-unlocked/node-builder:latestFROM ${NODE_BUILDER_IMAGE} AS builderWORKDIR /app# Generated workspace package.json copies (update via: pnpm generate:dockerfile-copies)# BEGIN GENERATED PACKAGE COPIES# ...# END GENERATED PACKAGE COPIESRUN pnpm install --frozen-lockfileCOPY packages/ ./packages/COPY apps/my-node-service/ ./apps/my-node-service/RUN pnpm --filter @erp-unlocked/my-node-service buildFROM node:22.22.0-alpine AS productionWORKDIR /appCOPY --from=builder /app/apps/my-node-service/dist ./distCOPY --from=builder /app/node_modules ./node_modulesEXPOSE 3000CMD ["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 -
Update
package.jsonscripts:{"scripts": {"start": "node dist/index.js","dev": "tsx watch src/index.ts","build": "tsc","test": "vitest run","lint": "eslint src/**/*.ts","typecheck": "tsc --noEmit"}}
Astro.js Applications
Section titled “Astro.js Applications”Directory Structure
Section titled “Directory Structure”apps/my-astro-app/├── src/│ ├── pages/│ ├── components/│ ├── layouts/│ └── content/├── public/├── dist/├── Dockerfile├── astro.config.mjs├── package.json├── tsconfig.json└── README.mdSetup Steps
Section titled “Setup Steps”-
Create the Astro v7 application under
apps/:Terminal window mkdir -p apps/my-astro-appcd apps/my-astro-apppnpm create astro@latest . -
Configure Astro for production:
astro.config.mjs import { defineConfig } from 'astro/config';export default defineConfig({output: 'server', // or 'static' for static sites like apps/docsbuild: {assets: 'assets',},server: {port: 4321,host: true,},}); -
Create Dockerfile:
Follow
apps/webapp/Dockerfileorapps/internal/Dockerfile, usingimages/node-builder/Dockerfile(node:22.22.0-alpinewithpnpm@10.33.4), runningpnpm generate:dockerfile-copies, and exposing/api/healthfor Cloud Run readiness probes. See Build Pipeline for the canonical multi-stage Cloud Build pattern.
GitHub Actions Configuration
Section titled “GitHub Actions Configuration”Update CI/CD Pipeline
Section titled “Update CI/CD Pipeline”See Build Pipeline for the complete runbook on adding a new service:
- Workspace
package.jsoncopies: Runpnpm generate:dockerfile-copiessoimages/node-builder/Dockerfile,apps/webapp/Dockerfile,apps/internal/Dockerfile, andapps/ts-temporal-worker/Dockerfileinclude the new app’spackage.json. - GCP Cloud Run / GCE VM services:
- Add change detection in
.github/workflows/gcp-deploy.yml - Add
docker-build-<app>anddeploy-<app>steps incloudbuild.yaml(remembering to keepserviceAccount: cloud-build-runner@ordermatic-prod...andlogging: CLOUD_LOGGING_ONLYon anycloudbuild*.yaml)
- Add change detection in
- Cloudflare Workers:
- Add a
wrangler.tomlinapps/<worker>/and create.github/workflows/deploy-<worker>.yml(modeled on.github/workflows/deploy-ops-mcp.ymlor.github/workflows/deploy-email-worker.yml).
- Add a
Validation in CI (validate.yml)
Section titled “Validation in CI (validate.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'Deployment Considerations
Section titled “Deployment Considerations”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:
- 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 toinfrastructure/infisical/secrets-map.json, whichvalidate-secrets-map.ymlenforces. - Health check — expose
/health(or/api/health) and point the platform’s readiness probe at it. - Changesets — add the app to
apps/<name>/in the pnpm workspace sopnpm changesetcan version it. - Internal-only? Put it behind Cloudflare Access with a
module "access_<app>"block (seeaccess_internalfor the pattern), and make the DNS record proxied — Access cannot intercept a grey-cloud record.
Service Communication
Section titled “Service Communication”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.
Best Practices Checklist
Section titled “Best Practices Checklist”For All Services
Section titled “For All Services”- Health check endpoint implemented
- Proper error handling and logging
- Environment variables for configuration
- Dockerfile follows multi-stage build pattern and
pnpm check:dockerfile-copiespasses - Tests cover core functionality
- CI/CD pipeline updated (
cloudbuild.yaml/.github/workflows/gcp-deploy.ymlor.github/workflows/deploy-<worker>.yml) - Documentation in README.md
- Security considerations addressed
Python Services
Section titled “Python Services”- Python 3.11+ and uv for dependency management
- Virtual environment in Docker
- OpenTelemetry for observability
- Pydantic for data validation
- Async/await where appropriate
Node.js Services
Section titled “Node.js Services”- 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.js Services
Section titled “Astro.js Services”- Astro v7 with Static vs SSR decision made
- Build optimization configured
- SEO considerations addressed
- Performance optimizations applied
Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”-
Service can’t communicate with others:
- Check Cloud Run IAM (
run.invoker) or local Docker Compose network configuration - Verify service names and ports
- Check Cloud Run IAM (
-
Build failures:
- Run
pnpm check:dockerfile-copiesto ensure workspacepackage.jsoncopy blocks are in sync - Remember Cloud Build’s classic builder does not enable BuildKit (
COPY --chmod=andRUN --mount=will fail) - See Build Pipeline
- Run
-
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
Debug Commands
Section titled “Debug Commands”# Check local service healthcurl http://localhost:4321/api/health
# View local Docker Compose service logsdocker compose logs -f webapp
# Check running containersdocker compose psConclusion
Section titled “Conclusion”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:
- Run
pnpm generate:dockerfile-copiesafter addingapps/<name>/package.json - Wire the deploy target into
cloudbuild.yaml+.github/workflows/gcp-deploy.yml(or.github/workflows/deploy-<worker>.yml) - Set up proper monitoring
- Update documentation
- Test service communication
For questions or additional service types, refer to the Build Pipeline runbook and existing implementations.