This document describes the requirements and conventions that must be followed when building an application to be hosted alongside the Workforce Hub platform.
Topics covered
Environment Variables & Secrets
Architecture requirements
Frontend (FE) and Backend (BE) must be fully separated β no monolithic deployments.
Both FE and BE must be independently deployable and independently scalable.
The application must be stateless β no data may be written to the filesystem at runtime. All persistent state must be stored in an external database or cache.
The application must not accumulate data in memory indefinitely. In-memory structures (caches, queues, buffers) must have a clearly defined eviction or expiry strategy implemented in application logic β not via an external scheduler. Memory usage must remain bounded under sustained load.
Docker requirements
Multi-stage builds
All Docker images must use multi-stage builds:
Stage 1 (build stage): Contains all build tools, compilers, and intermediate artifacts.
Stage 2 (runtime stage): Contains only the minimal output required to run the application β no build tools, source code, or intermediate files.
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM nginx:1.31.1-alpine3.23
COPY --from=builder /app/dist /usr/share/nginx/html
NGINX requirements
If the application uses NGINX as a web server or reverse proxy:
The base image must pin a specific NGINX version β always the latest stable release (e.g., nginx:1.31.1-alpine3.23). Using unversioned tags such as nginx:alpine or nginx:latest is not permitted.
The version must be updated with each image rebuild to track the latest stable release.
Version disclosure must be disabled by setting server_tokens off; in the NGINX configuration. This prevents NGINX from exposing its version in HTTP response headers and error pages.
http {
server_tokens off;
}
The runtime stage of the Dockerfile must include the following OpenShift compatibility configuration:
# Grant the root group (which OpenShift's arbitrary UID belongs to) read/write/execute
# access to the directories NGINX needs at runtime: cache, pid file, and logs.
# This is required because OpenShift runs containers as a random UID that is always
# a member of the root group (GID 0).
RUN chmod g+rwx /var/cache/nginx /var/run /var/log/nginx
RUN chgrp -R root /var/cache/nginx
# NGINX's master process normally starts as the 'nginx' user (defined by the 'user'
# directive in nginx.conf). In OpenShift the master process already runs as the
# assigned arbitrary UID, so the 'user' directive would cause NGINX to fail.
# Commenting it out lets NGINX inherit the UID from the container runtime.
RUN sed -i.bak 's/^user/#user/' /etc/nginx/nginx.conf
# Add the nginx system user to the root group so that any NGINX worker processes
# retain access to group-writable paths during local/non-OpenShift runs as well.
RUN usermod -a -G root nginx
# Expose port 8080 instead of the default 80. Ports below 1024 are privileged
# and cannot be bound by non-root processes, which is the case in OpenShift.
EXPOSE 8080
Port
Both the frontend and backend must listen on port 8080. Binding to privileged ports (below 1024) is not permitted.
OpenShift compatibility
Images must support running as any arbitrary user ID (non-root, no fixed UID):
Do not rely on a specific UID or GID.
Set directory and file permissions so the application is functional when the container runtime assigns a random UID.
Do not use USER root in the runtime stage.
Image architecture
Images must be built for linux/amd64 β this is mandatory.
Building for linux/arm64 is highly recommended to future-proof deployments.
Multi-arch images should be published as a single manifest list using docker buildx:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--sbom=true \
--provenance=mode=max \
--output type=registry \
-t registry.example.com/your-app:v1.0.3 .
SBOM & provenance
Every image must be built with SBOM (Software Bill of Materials) and provenance attestations enabled. The --sbom and --provenance flags are included in the multi-arch build command shown in the Image Architecture section above and must always be present.
Image tagging & versioning
All images must be tagged using semantic versioning with the v prefix:
v1.0.0, v1.0.1, v1.2.0, v2.0.0, ...
Tags are immutable β a published tag must never be overwritten or reused, regardless of environment (dev or prod).
Mutable or generic tags such as latest, dev, staging, main, or branch names are not permitted as the primary deployment tag.
Every deployment must reference a specific, immutable version tag.
Registry access
The container registry must be publicly accessible over the internet. VPN access and site-to-site network connections are not supported β the platform infrastructure must be able to pull images without any additional network configuration.
The client must provide the platform team with the following details to enable pulling the images:
Registry URL β the full URL of the container registry (e.g., registry.example.com)
Repository path(s) β the full path for each image (e.g., registry.example.com/org/appname-frontend)
Username β the service account or robot account used for authentication
Access token / password β a read-only pull token scoped to the relevant repositories
These credentials must be delivered securely and kept up to date. The platform team must be notified immediately if credentials are rotated or revoked.
Security & CVE policy
Docker images must contain no Critical or High CVEs at the time of deployment.
Images must be scanned before every release using an approved scanner (e.g., Trivy, Docker Scout, Grype).
If a Critical or High CVE is discovered post-deployment:
The company is obligated to resolve the issue within 1 business day, either by:
Releasing a patched image with an updated base image or dependency, or
Providing a detailed written remediation plan if no upstream fix is yet available (e.g., waiting on an upstream package fix), including the CVE ID, affected package, current upstream status, and estimated timeline.
Frontend requirements
Base path
The frontend application must be served from an agreed base path in the format:
/appname
Where appname consists of lowercase letters aβz only (no numbers, hyphens, or special characters).
Example: /myapp, /hrportal
The application must be configured to handle this base path correctly β all assets, routes, and API calls must be relative to this path.
Runtime domain detection
The domain must never be hardcoded and must not be configured at build time.
The application must detect its own host at runtime using the browser's window.location (or equivalent). Configuration endpoints or environment injection via a runtime config file served by the server are acceptable approaches.
const baseUrl = `${window.location.origin}/appname`;
Do not use environment variables like REACT_APP_API_URL=https://example.com baked into the build artifact.
Pagination
The frontend must support pagination for all list views. It must never request or attempt to display unbounded result sets.
Default page size must be 10.
Page size options may be offered to the user but must not exceed the backend maximum of 100.
Pagination controls (previous/next, page numbers, or infinite scroll with clear boundaries) must be visible and functional.
The frontend must read pagination metadata from the backend response and update the UI accordingly.
Authentication
Keycloak
Authentication is handled by a Keycloak instance running on the same domain as the application, at the path:
/auth
The Keycloak realm and client are pre-configured by the platform team.
The client uses public client flow (no client secret).
Client ID: saas
The application must use the Authorization Code Flow with PKCE.
Token refresh must be handled automatically by the frontend (silent refresh or refresh token rotation).
A test Keycloak environment can be provided upon request for integration testing during development.
Logout flow
When a user logs out, the application must:
Clear the active tenant from localStorage (remove the tenant key).
Clear any other application state stored in localStorage or sessionStorage.
Redirect the user to the Keycloak logout endpoint to invalidate the session server-side:
GET /auth/realms/{realm}/protocol/openid-connect/logout?post_logout_redirect_uri={encodedRedirectUri}&id_token_hint={idToken}
Skipping the Keycloak logout step leaves the SSO session active, meaning the user would be silently re-authenticated on the next visit without being prompted for credentials.
Keycloak configuration example
const keycloakConfig = {
url: `${window.location.origin}/auth`,
realm: 'ts-wfh',
clientId: 'saas',
};
Multi-Tenancy
JWT token structure
After a successful login, the JWT token issued by Keycloak contains multi-tenancy claims:
Tenant membership:
{
"Ts-Tenant-Ids": [
"dev",
"sandbox"
]
}
Roles per tenant:
{
"Ts-Tenant-Groups": {
"dev": [
"developer-user"
],
"sandbox": [
"super-user"
]
}
}
Data source access per tenant (applicable if the application supports data labeling/filtering):
{
"Ts-Tenant-Sources": {
"dev": [
"hr"
],
"sandbox": [
"all"
]
}
}
Tenant selection
After login, the application must present a tenant selection screen:
Display all tenants listed in Ts-Tenant-Ids.
If only one tenant is present, it must be automatically selected without user interaction.
The selected tenant must be persisted in localStorage under a well-defined key so that it survives page refreshes and is consistent across tabs.
On every subsequent page load, the application must read the active tenant from localStorage and skip the selection screen if a valid tenant is already stored.
If the stored tenant is no longer present in the current token's Ts-Tenant-Ids (e.g., access was revoked), the stored value must be cleared and the selection screen must be shown again.
const TENANT_KEY = 'tenant';
localStorage.setItem(TENANT_KEY, selectedTenantId);
const activeTenant = localStorage.getItem(TENANT_KEY);
API requests
Every request from the frontend to any backend API must include the following HTTP headers:
Authorization header
Authorization: Bearer eyJ...
The token is the raw JWT access token obtained from Keycloak. It must be refreshed before expiry.
Tenant header
Ts-Tenant-Id: dev
The value is the tenant identifier selected by the user at login. This header must be present on every request.
Example (Fetch)
const response = await fetch(`${baseApiUrl}/resource`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Ts-Tenant-Id': selectedTenantId,
},
});
Backend requirements
Base path
The backend API must be configured to run under a base path in the format:
/appname
Where appname consists of lowercase letters aβz only.
The platform proxy exposes the backend at:
/api/latest/appname
The backend must correctly handle requests arriving at its configured base path.
Token validation
The backend does not need to validate the JWT token. Authentication and authorization are handled upstream by the Workforce Hub platform's proxy layer before the request reaches the backend service.
The backend may read claims from the token if needed for business logic (e.g., extracting Ts-Tenant-Id), but must not reject requests based on token validity.
Tenant isolation
The Ts-Tenant-Id header received on every request is guaranteed to be valid β it will always be a single value from the Ts-Tenant-Ids claim in the user's JWT token. The platform proxy validates this before the request reaches the backend.
The backend must treat the Ts-Tenant-Id header as the authoritative tenant scope. All database queries and data access must be filtered by this tenant ID. Under no circumstances may the backend return data belonging to a different tenant. If the header is missing, the request must be rejected with 400 Bad Request.
Error handling
The backend must never return system-level or internal error details to the client. Errors such as database connection failures, missing tables, driver exceptions, or stack traces must be caught internally and returned as generic HTTP responses.
| Scenario | HTTP Status | Response Body |
| Database failure | 500 | { "error": "Internal server error" } |
| Unexpected exception | 500 | { "error": "Internal server error" } |
| Invalid input | 400 | { "error": "Bad request" } |
| Resource not found | 404 | { "error": "Not found" } |
Internal error details must only be written to the application logs (stdout) so the platform team can investigate. Exposing system errors to end users is a security risk.
CORS
The backend must respond with the following CORS header on all endpoints:
Access-Control-Allow-Origin: *
All standard HTTP methods and headers used by the frontend must be included in Access-Control-Allow-Methods and Access-Control-Allow-Headers respectively.
Health & readiness endpoints
The backend must expose two endpoints:
| Endpoint | Purpose |
| GET /appname/health/live | Liveness probe β returns 200 OK if the process is running and not deadlocked. Should only check that the application itself is alive (no external dependency checks). |
| GET /appname/health/ready | Readiness probe β returns 200 OK only when the application is fully initialized and capable of serving traffic. Should verify critical dependencies such as the database connection. Returns 503 if not ready. |
OpenShift uses these endpoints to decide whether to route traffic to the pod and whether to restart it. Without them, deployment stability cannot be guaranteed.
Logging
All application logs must be written to stdout (and errors to stderr). Writing logs to files is not permitted.
Logs must follow the format used across Workforce Hub backend services:
YYYY-MM-DD HH:MM:SS - LEVEL - tenant_id - message
Example (two log lines per request β path on arrival, status and duration on completion):
2026-05-25 10:00:00 - INFO - dev - Request path: /appname/resource
2026-05-25 10:00:00 - INFO - dev - Completed in 0.03 seconds with status code: 200
The tenant ID must be injected into every log line via context (e.g., ContextVar in Python) so it is available without being passed explicitly through every function call.
Do not log sensitive data (tokens, passwords, personal data).
Graceful shutdown
The application must handle the SIGTERM signal gracefully. When OpenShift stops a pod (during scaling, rolling deployments, or rescheduling), it sends SIGTERM before forcefully terminating the process.
On receiving SIGTERM the application must:
Stop accepting new connections.
Finish processing all in-flight requests.
Close database connections and release resources.
Exit cleanly.
Without graceful shutdown, in-flight requests will be abruptly terminated during every deployment.
OpenAPI specification
The client must provide and maintain an OpenAPI 3.x specification (openapi.yaml or openapi.json) covering all backend endpoints. This specification must:
Be submitted to the platform team before the initial deployment.
Be updated with every release that introduces new, modified, or removed endpoints.
Include request/response schemas, required headers (Authorization, Ts-Tenant-Id), and all possible response codes.
Database
The application may only use PostgreSQL as its database, unless a different database has been explicitly agreed upon in writing with the platform team.
The application must not rely on local file-based databases (e.g., SQLite) or in-process storage.
Connection strings and credentials must be provided via environment variables at runtime β never hardcoded.
The database schema must be named after the application (e.g., appname). The application must not use the public schema or any shared schema.
Database migrations
Every release that changes the database schema must include a migration script. There are no exceptions β even a single column addition requires a migration.
Migrations are executed automatically as part of every deployment, before the new application version starts serving traffic. The client must ensure the migration mechanism is built into the application startup process or the deployment pipeline β migrations must never be run manually.
Migrations must be idempotent β running the same migration twice must not fail or corrupt data.
Migrations must be backward-compatible where possible. The old version of the application must continue to work against the new schema until the deployment is complete.
The client must include migration scripts in the release email and specify whether any migration is non-backward-compatible (which may require a maintenance window).
Migration scripts must be versioned and ordered (e.g., V001__add_user_table.sql, V002__add_email_index.sql).
Migration history & execution
The application must maintain a migration history table in the database that records which migrations have already been executed.
On every startup, the application must scan all available migration scripts, compare them against the history table, and execute only those that have not yet been run.
Migrations must be applied in strict version order β no migration may be skipped.
If a migration fails, the deployment must fail immediately and the application must not start serving traffic. The platform team must be notified to investigate.
If a migration script is present in the codebase but missing from the history table (i.e., it was never executed), the application must execute it before starting.
The migration history table must be stored in the application schema (e.g., appname.flyway_schema_history or equivalent) and must never be modified manually.
Environment variables & secrets
All configuration values and secrets (database credentials, API keys, third-party service URLs, etc.) must be passed to the application as environment variables at runtime. They must never be hardcoded in the source code or baked into the Docker image.
The client must deliver the full list of required environment variables and their values to the platform team via email before the initial deployment and whenever new variables are introduced or existing ones change. The platform team will configure them as environment variables in the deployment.
The list must follow this format:
| Variable Name | Description | Required |
| DATABASE_URL | PostgreSQL connection string | Yes |
| SOME_API_KEY | API key for external service X | Yes |
Secrets must be transmitted securely β do not include them in plain text in tickets, chat messages, or public repositories.
Resource requirements
Before any deployment (including to the development environment), the client must provide resource estimates for each service (frontend and backend) via email to the platform team.
This information is mandatory β deployment cannot proceed without it.
The following must be specified per service:
| Parameter | Description | Example |
| requests.cpu | Guaranteed CPU the pod always has available | 100m |
| requests.memory | Guaranteed memory the pod always has available | 256Mi |
| limits.cpu | Maximum CPU the pod may consume | 500m |
| limits.memory | Maximum memory the pod may consume | 512Mi |
Estimates must be based on realistic load expectations. They will be reviewed with the platform team and may be adjusted based on observed usage after deployment.
Route & permission matrix
The client must maintain and keep up to date a route and permission matrix documenting which HTTP methods and paths each role may access. This matrix must be submitted to the platform team and updated whenever routes or roles change.
The matrix must follow this format:
| Method | Path | Roles Allowed |
| GET | /appname/resource | developer-user, super-user |
| POST | /appname/resource | super-user |
| DELETE | /appname/resource/{id} | super-user |
Note: Role names correspond to values found in the Ts-Tenant-Groups claim of the JWT token for the active tenant.
This matrix must be reviewed and updated with every release that introduces new or modified endpoints.
Release & deployment process
Whenever a new version of the application is ready for deployment, the client must notify the platform team via email before the deployment can be scheduled.
The notification email must include:
Version tag of the new image(s) (e.g., v1.2.0)
Full image reference(s) (e.g., registry.example.com/org/appname-frontend:v1.2.0)
Summary of changes β new features, bug fixes, breaking changes
Updated OpenAPI specification (if endpoints changed)
Updated Route & Permission Matrix (if routes or roles changed)
Updated environment variables (if new or changed variables are required)
Any special deployment instructions (e.g., database migrations that must run before the new version starts)
The platform team will confirm receipt and schedule the deployment. Deployments will not be triggered without this notification.
Comments
0 comments
Please sign in to leave a comment.