Complete reference for the k8s-ee.yaml configuration file used by ephemeral PR environments.
Minimal configuration requires only projectId:
# k8s-ee.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/koder-cat/k8s-ephemeral-environments/main/.github/actions/validate-config/schema.json
projectId: myappTip: Add the schema comment for IDE autocompletion and validation.
This creates PR environments at myapp-pr-{number}.k8s-ee.genesluna.dev with sensible defaults.
# k8s-ee.yaml - Full example with all options
projectId: myapp
trigger: automatic # or "on-demand" for /deploy-preview command
app:
port: 3000
healthPath: /health
metricsPath: /metrics
image:
context: .
dockerfile: Dockerfile
repository: ghcr.io/myorg/myapp # Optional, auto-generated if not set
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 384Mi
env:
NODE_ENV: production
LOG_LEVEL: info
envFrom:
- secretRef:
name: my-secret
- configMapRef:
name: my-config
databases:
postgresql: true
mongodb: false
redis: false
minio: false
mariadb: false
ingress:
enabled: true
annotations:
custom.annotation/key: value
metrics:
enabled: false
interval: 30sUnique identifier for this project in multi-tenant clusters.
| Property | Value |
|---|---|
| Type | string |
| Required | Yes |
| Min Length | 1 |
| Max Length | 20 |
| Pattern | ^[a-z0-9]([a-z0-9-]{0,18}[a-z0-9])?$ |
Validation Rules:
- Must be lowercase alphanumeric with hyphens
- Must start and end with alphanumeric character
- Maximum 20 characters (leaves room for
-pr-{number}suffix)
Examples:
projectId: myapp # Valid
projectId: my-cool-app # Valid
projectId: my-app-123 # Valid
projectId: MyApp # Invalid: uppercase
projectId: my_app # Invalid: underscore
projectId: -myapp # Invalid: starts with hyphen
projectId: this-is-a-very-long-project-name # Invalid: too longControls how PR environments are created.
| Property | Value |
|---|---|
| Type | string |
| Required | No |
| Default | automatic |
| Values | automatic, on-demand |
automatic (default): Environment is created automatically when a PR is opened or updated. This is the standard behavior.
on-demand: Environment is only created when someone comments /deploy-preview on the PR. After creation, subsequent pushes auto-redeploy. Use /destroy-preview to tear down the environment early.
trigger: on-demandNote: On-demand mode requires
trigger: on-demandin yourk8s-ee.yaml. The universal workflow template from the Onboarding Guide handles both modes — once you have it, switching is a one-line config change. See On-Demand Environments for details.
Application settings for the deployed container.
| Property | Value |
|---|---|
| Type | integer |
| Default | 3000 |
| Minimum | 1 |
| Maximum | 65535 |
Container port the application listens on. The platform automatically configures:
- Deployment: Sets the container port
- NetworkPolicy: Allows ingress traffic on this port from Traefik
Common Configurations:
app:
port: 3000 # Node.js, Express, NestJS (default)
port: 8080 # .NET, Go, Java Spring Boot
port: 8000 # Python FastAPI, Django| Property | Value |
|---|---|
| Type | string |
| Default | "/health" |
| Pattern | ^/.* |
Health check endpoint path used for liveness and readiness probes.
app:
healthPath: /api/health| Property | Value |
|---|---|
| Type | string |
| Default | (none) |
| Pattern | ^/.* |
Metrics endpoint path for Prometheus scraping. Only needed if metrics.enabled: true.
app:
metricsPath: /metricsDocker image build configuration.
| Property | Value |
|---|---|
| Type | string |
| Default | "." |
Docker build context path relative to repository root.
image:
context: ./backend| Property | Value |
|---|---|
| Type | string |
| Default | "Dockerfile" |
Path to Dockerfile relative to the build context.
image:
dockerfile: Dockerfile.prod| Property | Value |
|---|---|
| Type | string |
| Default | (auto-generated) |
Custom image repository URL. If not set, auto-generated based on the registry type:
- GHCR (default):
ghcr.io/{owner}/{repo}/{project-id} - ECR:
<account-id>.dkr.ecr.<region>.amazonaws.com/{owner}/{repo}/{project-id}
image:
repository: ghcr.io/myorg/custom-imageContainer resource requests and limits. Must fit within cluster LimitRange (max 512Mi memory per container).
| Property | Value |
|---|---|
| Type | string |
| Default | "50m" |
| Pattern | ^[0-9]+(m|[0-9]*)?$ |
CPU request in millicores (e.g., 50m, 100m, 0.5).
| Property | Value |
|---|---|
| Type | string |
| Default | "128Mi" |
| Pattern | ^[0-9]+(Mi|Gi)$ |
Memory request (e.g., 128Mi, 256Mi, 1Gi).
| Property | Value |
|---|---|
| Type | string |
| Default | "200m" |
| Pattern | ^[0-9]+(m|[0-9]*)?$ |
CPU limit in millicores.
| Property | Value |
|---|---|
| Type | string |
| Default | "384Mi" |
| Pattern | ^[0-9]+(Mi|Gi)$ |
Memory limit. Maximum allowed: 512Mi (cluster LimitRange).
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512MiUser-defined environment variables injected into the application pod as key-value pairs. These are stored in a Kubernetes ConfigMap ({namespace}-app-config) and mounted via envFrom.
| Property | Value |
|---|---|
| Type | object |
| Default | {} |
| Key Pattern | ^[A-Za-z_][A-Za-z0-9_]*$ |
env:
NODE_ENV: staging
LOG_LEVEL: info
JWT_SECRET: "my-ephemeral-secret-not-for-production"
AUTH_BYPASS_LDAP: "true"How it works: The env values flow through the deployment pipeline:
validate-configparsesk8s-ee.yamland outputs them asenv-json- The reusable workflow passes
env-jsonto thedeploy-appaction deploy-apppasses them to Helm via--set-json env=...- The Helm chart injects them into the app ConfigMap alongside platform variables (PORT, PR_NUMBER, etc.)
Verification: To confirm your env vars reached the pod:
kubectl get configmap {namespace}-app-config -n {namespace} -o yamlNote: Database connection variables (DATABASE_URL, PGHOST, MINIO_ENDPOINT, etc.) are injected separately by database charts and do not need to be listed in
env. Variables likeMINIO_BUCKETthat are configured viadatabases.minio.bucketare also injected automatically.
Environment variables from Kubernetes secrets or configmaps.
| Property | Value |
|---|---|
| Type | array |
| Default | [] |
Each item must have exactly one of secretRef or configMapRef (not both).
envFrom:
- secretRef:
name: database-credentials
- configMapRef:
name: my-shared-configDirectories that need to be writable at runtime. Each directory is mounted as an emptyDir volume. Required because containers run with readOnlyRootFilesystem: true for security.
| Property | Value |
|---|---|
| Type | array of strings |
| Default | [] |
| Path Pattern | Must start with / |
writableDirs:
- /app/upload
- /app/dataNote:
/tmpis always writable (mounted automatically). Only declare directories outside of/tmpthat your app needs to write to.
Database configuration. All databases are disabled by default (opt-in).
When enabled, databases are automatically deployed to your PR environment and connection details are injected as environment variables (e.g., DATABASE_URL for PostgreSQL).
Each database can be configured as:
- Boolean:
trueto enable with defaults,falseto disable - Object: Enable with custom configuration
| Property | Value |
|---|---|
| Type | boolean | object |
| Default | false |
PostgreSQL database using CloudNativePG operator.
# Simple enable
databases:
postgresql: true
# With custom configuration
databases:
postgresql:
enabled: true
version: "16"
storage: 2Gi
# With bootstrap SQL (for table creation)
databases:
postgresql:
enabled: true
bootstrap:
postInitApplicationSQL:
- |
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255)
);
GRANT ALL PRIVILEGES ON users TO app;
GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO app;Object properties:
enabled: boolean (default: true)version: string (default: "16")storage: string (default: "1Gi", pattern:^[0-9]+(Mi|Gi|Ti)$)bootstrap.postInitApplicationSQL: array of SQL strings to run after database creationbootstrap.initSQL: array of SQL strings to run on postgres database (for extensions)
Important: Bootstrap SQL runs as postgres superuser, but your app connects as app user. You must include GRANT statements for table access. Use $func$ instead of $$ for function delimiters.
Note: For production applications with evolving schemas, use Drizzle ORM migrations instead of bootstrap SQL. Migrations provide versioning, are reversible, and work with existing data.
| Property | Value |
|---|---|
| Type | boolean | object |
| Default | false |
MongoDB database using MongoDB Community Operator.
databases:
mongodb:
enabled: true
storage: 2GiObject properties:
enabled: boolean (default: true)version: stringstorage: string (default: "1Gi", pattern:^[0-9]+(Mi|Gi|Ti)$)
| Property | Value |
|---|---|
| Type | boolean | object |
| Default | false |
Redis cache using simple deployment.
databases:
redis: trueObject properties:
enabled: boolean (default: true)
| Property | Value |
|---|---|
| Type | boolean | object |
| Default | false |
MinIO object storage using MinIO Operator.
databases:
minio:
enabled: true
storage: 5GiObject properties:
enabled: boolean (default: true)storage: string (default: "1Gi", pattern:^[0-9]+(Mi|Gi|Ti)$)
| Property | Value |
|---|---|
| Type | boolean | object |
| Default | false |
MariaDB database using simple deployment.
databases:
mariadb:
enabled: true
version: "11.4"
storage: 2GiObject properties:
enabled: boolean (default: true)version: string (default: "11.4")storage: string (default: "1Gi", pattern:^[0-9]+(Mi|Gi|Ti)$)
The platform automatically calculates ResourceQuota based on enabled databases. Each PR namespace receives a quota sized for its specific configuration.
| Service | CPU Limit | Memory Limit | Storage |
|---|---|---|---|
| Application (base) | 300m | 512Mi | 1Gi |
| PostgreSQL | +500m | +512Mi | +2Gi |
| MongoDB | +500m | +512Mi | +2Gi |
| Redis | +200m | +128Mi | - |
| MinIO | +500m | +512Mi | +2Gi |
| MariaDB | +300m | +256Mi | +2Gi |
Example Calculated Quotas:
| Configuration | CPU Limit | Memory Limit | Storage |
|---|---|---|---|
| App only | 300m | 512Mi | 1Gi |
| App + PostgreSQL | 800m | 1Gi | 3Gi |
| App + PostgreSQL + Redis | 1000m | 1.1Gi | 3Gi |
| App + PostgreSQL + MongoDB | 1300m | 1.5Gi | 5Gi |
| All databases enabled | 2100m | 2.4Gi | 9Gi |
The quota is calculated at namespace creation time based on the databases section in your k8s-ee.yaml. No manual intervention is required.
Note: Quotas are calculated once at namespace creation. If you add databases to an existing PR environment, close and reopen the PR to recalculate quotas, or manually patch the ResourceQuota.
Note: These are approximate values. Actual consumption varies based on workload and operator versions.
Ingress configuration for external access.
| Property | Value |
|---|---|
| Type | boolean |
| Default | true |
Enable/disable ingress creation.
| Property | Value |
|---|---|
| Type | object |
| Default | {} |
Additional annotations for the ingress resource.
ingress:
enabled: true
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
traefik.ingress.kubernetes.io/rate-limit: "100"Prometheus metrics configuration.
| Property | Value |
|---|---|
| Type | boolean |
| Default | false |
Enable ServiceMonitor for Prometheus scraping.
| Property | Value |
|---|---|
| Type | string |
| Default | "30s" |
| Pattern | ^[0-9]+(s|m|h)$ |
Scrape interval (e.g., 15s, 1m, 5m).
metrics:
enabled: true
interval: 15sWhen metrics are enabled, the ServiceMonitor automatically adds a namespace label to all scraped metrics using Prometheus relabeling. This enables Grafana dashboards to filter metrics by namespace without requiring your application to add this label.
Your application metrics will include namespace="myapp-pr-42" automatically, matching the PR environment namespace.
When metrics.enabled: true, the platform deploys a ServiceMonitor that tells Prometheus to scrape GET /metrics on your app. Your app must expose this endpoint returning Prometheus text format — otherwise the Grafana "PR Developer Insights" dashboard will show App Status = DOWN and DB Connected = NO.
These metrics power the Grafana dashboard panels:
| Metric Name | Type | Labels | Dashboard Panels |
|---|---|---|---|
http_requests_total |
Counter | method, route, status_code |
Request Rate, Error Rate, 5xx by Endpoint, Requests by Status |
http_request_duration_seconds |
Histogram | method, route, status_code |
P95 Latency, P95 by Endpoint, Slowest Endpoints |
db_pool_connections_total |
Gauge | — | DB Connected, Connection Pool |
db_pool_connections_idle |
Gauge | — | Connection Pool |
db_pool_connections_waiting |
Gauge | — | Connection Pool |
db_query_duration_seconds |
Histogram | operation, success |
Query Duration, Failed Queries |
Minimum requirements: A /metrics endpoint returning Prometheus text format, plus http_requests_total and http_request_duration_seconds. The database metrics are optional but recommended if your app uses a database.
Automatic metrics: The up metric (App Status panel) and kube_pod_status_phase (Pods Running panel) are provided automatically by Prometheus and kube-state-metrics — no app instrumentation needed.
HTTP duration: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] (seconds)
DB query duration: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1] (seconds)
import * as client from 'prom-client';
const registry = new client.Registry();
registry.setDefaultLabels({
app: 'my-app',
pr: process.env.PR_NUMBER || 'unknown',
});
client.collectDefaultMetrics({ register: registry });
const httpRequestTotal = new client.Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [registry],
});
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [registry],
});
// GET /metrics endpoint
app.get('/metrics', async (_req, res) => {
res.set('Content-Type', registry.contentType);
res.send(await registry.metrics());
});Reference implementation: See
demo-app/apps/api/src/metrics/for a complete example with HTTP middleware, database metrics, and pool monitoring.
projectId: my-api
app:
port: 3000
healthPath: /health
env:
NODE_ENV: production
databases:
postgresql: trueprojectId: flask-app
app:
port: 5000
healthPath: /healthz
resources:
requests:
memory: 256Mi
limits:
memory: 512Mi
databases:
redis: trueprojectId: fullstack
app:
port: 8080
healthPath: /api/health
metricsPath: /metrics
databases:
postgresql: true
redis: true
minio:
enabled: true
storage: 2Gi
metrics:
enabled: true
interval: 30sSave resources by creating environments only when needed:
projectId: myapp
trigger: on-demand
app:
port: 3000
healthPath: /health
databases:
postgresql: trueWith this configuration, environments are only created when someone comments /deploy-preview on the PR. Use /destroy-preview to tear down the environment early. Requires the universal workflow template from the Onboarding Guide.
projectId: backend-svc
image:
context: ./services/backend
dockerfile: Dockerfile
app:
port: 4000
healthPath: /ready
envFrom:
- secretRef:
name: shared-secretsThe schema validation provides clear error messages:
| Error | Cause | Fix |
|---|---|---|
projectId must match pattern |
Invalid characters or format | Use lowercase alphanumeric + hyphens only |
projectId must be <= 20 characters |
ID too long | Shorten the project ID |
app.port must be >= 1 |
Invalid port number | Use port between 1-65535 |
resources.limits.memory must match pattern |
Invalid memory format | Use format like 256Mi or 1Gi |
databases.*.storage must match pattern |
Invalid storage format | Use format like 1Gi, 500Mi, or 2Ti |
env property name is invalid |
Invalid env var name | Use pattern [A-Za-z_][A-Za-z0-9_]* |
envFrom item must have secretRef or configMapRef |
Empty envFrom entry | Specify either secretRef or configMapRef |
These values are automatically computed and added to the configuration:
| Field | Formula | Example |
|---|---|---|
_computed.namespace |
{projectId}-pr-{prNumber} |
myapp-pr-42 |
_computed.previewUrl |
https://{namespace}.{domain} |
https://myapp-pr-42.k8s-ee.genesluna.dev |
_computed.prNumber |
From workflow input | 42 |
In addition to user-defined env values, the platform automatically injects these environment variables into every app pod:
| Variable | Source | Example |
|---|---|---|
PORT |
app.port config |
3000 |
PR_NUMBER |
Workflow input | 42 |
COMMIT_SHA |
Git HEAD SHA | a1b2c3d4... |
BRANCH_NAME |
PR head branch | feat-my-feature |
APP_VERSION |
Image tag | pr-42 |
PREVIEW_URL |
Computed preview URL | https://myapp-pr-42.k8s-ee.genesluna.dev |
Database charts inject additional variables when databases are enabled (e.g., DATABASE_URL, PGHOST, MINIO_ENDPOINT).
CORS tip: If your application has a CORS allowlist, add
process.env.PREVIEW_URLto it so the frontend served from the preview domain can make API calls. Example (Express.js):const allowedOrigins = [ 'http://localhost:3000', process.env.PREVIEW_URL, // k8s-ee preview domain ].filter(Boolean);
- Onboarding New Repository - Getting started guide
- Database Setup - Database configuration details
- Troubleshooting - Common issues and solutions