Workspaces Guide
This guide covers how to work with monorepo workspaces using the @geekmidas toolbox.
Overview
The CLI supports workspace mode for monorepos, enabling:
- Unified development server orchestration across multiple apps
- Smart dependency-aware build ordering
- Shared configuration with app-specific overrides
- Automatic client regeneration when API changes
Workspace Structure
A typical workspace layout:
my-monorepo/
├── apps/
│ ├── api/ # Backend API
│ │ ├── src/
│ │ │ └── endpoints/
│ │ └── gkm.config.ts
│ ├── web/ # Frontend (Next.js, etc.)
│ │ └── package.json
│ └── admin/ # Admin dashboard
│ └── package.json
├── packages/
│ ├── shared/ # Shared utilities
│ └── types/ # Shared TypeScript types
├── gkm.config.ts # Root workspace config
├── pnpm-workspace.yaml
└── turbo.jsonWorkspace Configuration
Root Configuration
// gkm.config.ts (root)
import { defineWorkspace } from '@geekmidas/cli/config';
export default defineWorkspace({
// The scope every physical name is built from: `Database` becomes
// `production-my-monorepo-database` on Dokploy and on AWS alike.
name: 'my-monorepo',
stages: { local: 'dev', deployed: ['prod'] },
// One glob, every kind. A database implies Postgres, a bucket implies MinIO,
// mail implies Mailpit — none of it listed anywhere. It is also where the
// apps come from: a `StaticSite` is an app, and so is a `RestApi` that named
// one.
constructs: './constructs/**/*.ts',
deploy: {
default: 'dokploy',
},
});That is a whole workspace config. There is no services block: which infrastructure exists is what the constructs declare, and which backend each one resolves to follows from the deploy target:
on dokploy / docker | on aws | |
|---|---|---|
Cache | a table in the database — the pool is already open | Upstash — reachable from a Lambda with no VPC |
ObjectStorage | MinIO | S3 |
Topic / Queue | pg-boss, in the Postgres already there | SNS and SQS (the AWS emulator locally) |
Email | whichever SMTP provider the stage's mail URL names | the same |
There is nothing to override. A config with a services key fails to load — the schema is strict — rather than being quietly ignored.
Compose: two files, one stack
The containers a project needs are derived from its constructs and written to docker-compose.constructs.yml at the project root — by gkm dev, gkm test, gkm setup and gkm docker alike. It is regenerated on every run, gitignored, and never edited.
A database's own image is not one of those: name it on the database (image: 'postgis/postgis:18-3.6-alpine', extensions: ['postgis']), so the deployed cluster runs it too.
Anything a construct cannot express goes in your own docker-compose.yml, beside it: an image pin, an extra service, a volume or port tweak. gkm merges it over the generated file, so yours wins:
docker compose -f docker-compose.constructs.yml -f docker-compose.yml …# docker-compose.yml — yours, optional
services:
mailpit:
image: axllent/mailpit:v1.27 # a pin, merged over the derived mailpit
pgadmin: # a service no construct implies
image: dpage/pgadmin4The apps sit in the generated file too, behind the apps profile, built from the Dockerfiles gkm docker writes and wired to every construct's own keys on the compose network — ORDERS_URL, a schema tenant's URL, the bucket, the mailer — so gkm dev starts only the containers. To run everything:
gkm docker # Dockerfiles, and the file above
gkm setup # start the containers and create what they hold
docker compose -f docker-compose.constructs.yml --profile apps up --buildEach app's Dockerfile builds on Node 22.23.4-alpine, a Node security release, through a NODE_IMAGE build argument. To run an app on another version, set it in your docker-compose.yml, which is merged over the generated file:
# docker-compose.yml — yours
services:
api:
build:
args:
NODE_IMAGE: node:24.22.1-alpineOutside compose, docker build --build-arg NODE_IMAGE=node:24.22.1-alpine does the same.
There is no apps block
There used to be one, naming each app with its type, path, port, framework and dependencies — every one of which was already declared. StaticSite('Web', { path: 'apps/web' }) and apps.web = { type: 'web', path: 'apps/web', framework: 'vite', dependencies: ['api'] } are the same sentence written twice, and only one of the two was checked against anything. So the copy that could drift is gone, and the apps are read off the graph:
// constructs/api.ts — a backend app
export const api = new RestApi('Api', {
path: 'apps/api',
defaultAuthorizer: 'none',
logger,
});
// constructs/site.ts — two frontend apps, and the edges that order them
export const web = new StaticSite('Web', { path: 'apps/web' }).dependsOn([api, auth]);
export const admin = new StaticSite('Admin', { path: 'apps/admin', variant: 'next' })
.dependsOn([api]);No paths: Web means apps/web and Admin means apps/admin, which is what the ids already said. Write one only when the layout differs.
.dependsOn() is where dependencies: ['api'] went. It is the same fact, but it is the one the build already needed — it derives the environment, the client and the deploy order from it.
Every surface gets a container, and nobody asks for one. There is no arrangement in which one runs inside another — two surfaces in a process share a filesystem, an environment and every credential either was granted, so an auth server beside an API is one bug in the API away from being read by it.
Apps that share a database
An app that shares a database with another declares a derived form of it — database.schema() for its own schema and role, or database.reader() for read-only access — rather than reaching for the same URL by string.
TIP
Use defineWorkspace() (not defineConfig()) for multi-app workspaces.
Stages
A project names its own stages, once, and every command reads them:
export default defineWorkspace({
name: 'my-saas',
stages: {
// What `gkm dev`, `exec`, `setup` and `test` run as.
local: 'dev',
// What `gkm deploy --stage` accepts.
deployed: ['staging', 'prod'],
// Retained and protected when a stack is removed.
protected: ['prod'],
},
constructs: './constructs/**/*.ts',
});stages is required, and nothing in the CLI assumes a stage name:
- Local commands default to
stages.local: its secrets are the onesgkm devloads (.gkm/secrets/dev.jsonabove), and its containers are the only ones without a stage suffix. gkm deploy --stagerefuses a stage that is not indeployed, before anything is provisioned — a typo does not create a second environment.gkm initasks for them, and writes adeploy:<stage>script for each deployed stage.
The config is checked when it loads:
| Rule | Why |
|---|---|
| Lowercase letters, digits and hyphens, starting with a letter | A stage is part of every physical name |
local is not also in deployed | Secrets are stored per stage, so a deployed stage sharing the local name would share its secrets with every developer's machine |
protected ⊆ deployed | Only a deployed stage has resources to keep |
No stage is called test | It is reserved for gkm test |
Upgrading
Projects created before stages existed ran locally as development. Add stages: { local: 'development', deployed: [...] } to keep using .gkm/secrets/development.json, or rename that file (and its key in ~/.gkm/keys/<namespace>/<project>/) to the new local stage.
Typed stages
Constructs and application code cannot import gkm.config.ts, so gkm dev, gkm build and gkm test --prepare write the stage names into .gkm/stages.d.ts, beside the generated clients:
// .gkm/stages.d.ts — generated, gitignored with the rest of .gkm/
export {};
declare module '@geekmidas/constructs' {
interface Stages {
local: 'dev';
deployed: 'staging' | 'prod';
}
}@geekmidas/constructs reads that interface as LocalStage, DeployedStage and AnyStage (the local stage, test, or a deployed one), and everything that takes a stage name is typed by them:
import type { SeedContext } from '@geekmidas/constructs';
import { ExternalApi } from '@geekmidas/constructs/external-api';
export const shipping = new ExternalApi('Shipping', {
// A key that is no declared stage — `prodution` — is a type error.
url: { prod: 'https://api.carrier.example', default: 'https://sandbox.carrier.example' },
// …
});
// db/database/seeds/001_demo.ts
export async function seed(db: Kysely<Database>, { stage }: SeedContext) {
if (stage === 'dev') await insertDemoTenant(db); // 'dev' | 'test' | 'staging' | 'prod'
}TypeScript never matches a dot folder with a wildcard, so the tsconfig names the file — gkm init writes this for you:
{ "include": ["src/**/*.ts", ".gkm/stages.d.ts"] }Until the file exists — a fresh clone, before the first gkm dev — every stage type is string, so nothing fails to compile for want of it. A CI typecheck runs after gkm test --prepare, which writes it.
There Is One Config, at the Root
Apps in a workspace do not carry a gkm.config.ts of their own. Everything an app used to state there it states in the declaration that makes it an app — where its code lives, what it depends on, which logger and environment parser it runs with:
// constructs/api.ts
export const api = new RestApi('Api', {
path: 'apps/api',
defaultAuthorizer: 'none',
// The actual logger, not a path to one. It used to be
// `logger: './src/config/logger'` in config, because the build wrote an
// import into each generated handler. Endpoints built from the surface carry
// it, so there is nothing to print and nothing to keep in step with a moved
// file.
logger,
telescope,
});A standalone single-app project — one app, no workspace — still uses defineConfig(), which is the last place the older shape survives:
// gkm.config.ts
import { defineConfig } from '@geekmidas/cli/config';
export default defineConfig({
stages: { local: 'dev', deployed: ['prod'] },
constructs: './src/constructs/**/*.ts',
routes: './src/endpoints/**/*.ts',
envParser: './src/config/env#envParser',
logger: './src/config/logger',
});These three are module paths, and the surface replaced them
logger: './src/config/logger' is a path the build prints into every generated handler — checked by nothing, and wrong the moment the file moves. logger on a RestApi is the logger itself.
defineConfig still requires all three. Prefer a workspace: defineWorkspace takes the constructs glob alone, and everything else is read off what the constructs declare.
Development Workflow
Starting All Apps
# From workspace root
gkm dev
# Output:
# [api] Starting on http://localhost:3000
# [api] Telescope: http://localhost:3000/__telescope
# [web] Waiting for api...
# [api] Ready
# [web] Starting on http://localhost:3001
# [admin] Starting on http://localhost:3002Starting Specific Apps
# Start only API
gkm dev --app api
# Start API and web
gkm dev --app api --app webBuild Order
The CLI automatically determines build order based on dependencies:
gkm build
# Builds in order:
# 1. api (no dependencies)
# 2. web (depends on api)
# 3. admin (depends on api)Frontend Integration
Auto-Generated API Client
Each API's typed client belongs to the application, like its manifest: it is generated at the workspace root, one file per surface, and every app reaches it through one alias — no package export, no copy step:
// apps/web/src/lib/api.ts
import { createApi } from '@myapp/client/api';new RestApi('Api') writes .gkm/client/api.ts; a second surface, new RestApi('Webhooks'), writes .gkm/client/webhooks.ts. gkm init maps the alias in the root tsconfig and in each frontend's own (an app's paths replaces the root's rather than merging with it):
{ "compilerOptions": { "paths": { "@myapp/client/*": ["../../.gkm/client/*"] } } }To regenerate the clients after endpoint changes:
gkm openapi # regenerates every surface's client
gkm build # also regenerates as part of the buildShared Packages
Creating Shared Code
// packages/shared/src/index.ts
export * from './utils';
export * from './constants';Using in Apps
// apps/api/src/endpoints/users.ts
import { formatDate } from '@myorg/shared';
import { api } from '../constructs/api';
export const getUser = api
.get('/users/:id')
.handle(async ({ params }) => {
const user = await db.users.find(params.id);
return {
...user,
createdAt: formatDate(user.createdAt),
};
});Environment Variables
What the Workspace Resolves
Every address comes from what the workspace declares: databases, caches, mail, buckets, the broker, and each app's URL behind the edge. You don't write DATABASE_URL or API_URL anywhere — gkm dev, gkm exec and gkm test inject them, and they win over anything in .env or a stage's secrets, so a stale value there cannot point an app at the wrong port.
What You Supply
Values nothing can derive — a third party's API key, a Credential — come from the stage's secrets (gkm secrets:set) or .env:
# .env (root)
STRIPE_SECRET_KEY=sk_test_…Variable Resolution
The CLI resolves environment variables in order:
- App-specific
.env.local - App-specific
.env - Root
.env.local - Root
.env - System environment
Turbo Integration
The CLI integrates with Turbo for task orchestration:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".gkm/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["^build"]
}
}
}Running with Turbo
# Build all packages and apps
turbo run build
# Run tests across workspace
turbo run test
# Development (prefer gkm dev for orchestration)
gkm devDocker Workspace Builds
Multi-App Docker Compose
gkm dockerWrites a Dockerfile per app under .gkm/docker/, and docker-compose.constructs.yml at the root with the containers the constructs imply and each app behind the apps profile — see Compose: two files, one stack.
Individual App Dockerfiles
# apps/api/Dockerfile
FROM node:22.23.4-alpine AS builder
WORKDIR /app
# Copy workspace files
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/ ./packages/
# Install and build
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm turbo run build --filter=api
# Production
FROM node:22.23.4-alpine
WORKDIR /app
COPY --from=builder /app/apps/api/.gkm/server/dist ./
EXPOSE 3000
CMD ["node", "app.js"]Deployment Strategies
Deploy Individual Apps
# Deploy only API
gkm deploy --app api --stage production
# Deploy frontend
gkm deploy --app web --stage productionDeploy All Apps
# Deploy entire workspace
gkm deploy --stage productionStaged Rollout
# 1. Deploy API first
gkm deploy --app api --stage production
# 2. Verify API health
curl https://api.example.com/health
# 3. Deploy frontends
gkm deploy --app web --app admin --stage productionBest Practices
1. Keep Shared Code Minimal
Only share code that's truly reused:
- Type definitions
- Utility functions
- Constants and configurations
2. Use Explicit Dependencies
Always declare dependencies between apps:
// constructs/site.ts
export const web = new StaticSite('Web', { path: 'apps/web' })
.dependsOn([api]); // Explicit3. Isolate Environment Variables
Keep app-specific variables in app directories:
apps/api/.env.local # API-specific
apps/web/.env.local # Web-specific
.env # Shared infrastructure4. Use Consistent Versioning
Keep package versions in sync:
// package.json (root)
{
"pnpm": {
"overrides": {
"zod": "^3.22.0",
"hono": "^4.0.0"
}
}
}5. Separate Build and Deploy
# CI: Build all, test all
turbo run build test
# CD: Deploy independently
gkm deploy --app api --stage production
gkm deploy --app web --stage productionTroubleshooting
Port Conflicts
Every app runs on the port the workspace provisioned for it, and gkm dev checks them all before starting anything. When one is held it stops with WorkspacePortsInUse, naming the port and what holds it — usually a dev server a previous run left behind:
# See what is listening on it
lsof -nP -iTCP:3000 -sTCP:LISTENStop that process and run gkm dev again. To run a single app somewhere else, pass the port explicitly: gkm dev --app api --port 3100.
Dependency Cycles
If you see "Circular dependency detected":
- Review your
dependsOnconfiguration - Extract shared code to a package instead
- Use event-driven communication
Stale Client Types
If frontend types are out of sync:
# Regenerate the API's typed client
gkm buildBuild Cache Issues
# Clear Turbo cache
turbo run build --force
# Clear gkm artifacts
rm -rf apps/*/.gkm