Treat internal infrastructure as a product with users, roadmaps, and SLOs. Covers product thinking for platform teams, developer experience metrics, adoption tracking, feedback loops, and the organizational model that makes platform engineering sustainable.
Infrastructure as Product is the practice of designing, building, and operating infrastructure not as a set of services, but as a product—self-service, discoverable, reliable, and iteratively improved with clear ownership, feedback loops, and SLAs. It matters when teams across an organisation repeatedly deploy, scale, and operate applications on shared systems, and when the infrastructure team is no longer a support function but a product team delivering value to developers and business stakeholders.
To provision a new service, a developer starts with a platform.yaml in their project repository:
name: user-service
runtime: nodejs
environment: production
dependencies:
- database: postgres
- cache: redis
- messaging: rabbitmq
This file is checked into Git. The platform's event processor, triggered by git push, reads the file and begins provisioning. The system sends an HTTP POST to https://platform.example.com/api/v1/provision with a JSON body:
{
"project": "user-service",
"branch": "main",
"configuration": {
"runtime": "nodejs",
"environment": "production",
"dependencies": [
{ "name": "database", "type": "postgres", "version": "15" },
{ "name": "cache", "type": "redis", "version": "7" },
{ "name": "messaging", "type": "rabbitmq", "version": "3.12" }
]
}
}
The response is a 202 Accepted with a Location header pointing to a tracking endpoint:
HTTP/1.1 202 Accepted
Location: /api/v1/provision/12345
Content-Type: application/json
{
"id": "12345",
"status": "processing",
"started_at": "2024-04-05T12:34:56Z",
"next_steps": [
"waiting for database to be ready",
"deploying application image to cluster"
]
The developer polls GET /api/v1/provision/12345 until status becomes completed. If an error occurs, the error field is populated:
{
"id": "12345",
"status": "failed",
"started_at": "2024-04-05T12:34:56Z",
"error": {
"code": "DATABASE_TIMEOUT",
"message": "Database provisioning timed out after 10 minutes",
"details": {
"provisioning_steps": [
"creating namespace",
"applying Helm chart",
"waiting for Pod to be ready"
],
"timeout": 600,
"actual_duration": 612
}
}
}
A failure at this stage silently leaves the service in a partially configured state: the database exists in the cluster but the application pod is stuck in Pending due to a missing ServiceAccount. The developer must inspect kubectl get pods -n user-service -o wide to find that the pod lacks a Secret named db-credentials, which was not created because the PostgreSQL instance failed to bootstrap.
Once provisioned, a service is managed through a configuration API. The platform.yaml is versioned and merged into a central configuration registry. Each configuration is versioned and served via a REST API:
GET /api/v1/configs/user-service@v3
The response includes the current state, dependencies, and metadata:
{
"id": "user-service@v3",
"name": "user-service",
"version": "3",
"created_at": "2024-04-05T13:00:00Z",
"updated_at": "2024-04-05T13:00:00Z",
"lifecycle": {
"status": "active",
"current_revision": "12345",
"last_updated_by": "devops-team",
"environment": "production"
},
"dependencies": [
{
"name": "database",
"type": "postgres",
"version": "15",
"status": "ready",
"url": "https://postgres.user-service.example.com",
"credentials": {
"username": "user",
"password": "secret",
"host": "postgres.user-service.example.com",
"port": 5432
}
}
],
"secrets": [
{
"name": "JWT_SECRET",
"source": "vault",
"path": "secrets/auth/jwt"
}
]
}
When a developer updates platform.yaml, they create a new configuration revision. The system compares the new version with the current one and computes a change diff:
{
"change_type": "upgrade",
"from": "user-service@v2",
"to": "user-service@v3",
"changes": [
{
"type": "add",
"resource": "messaging",
"details": {
"name": "messaging",
"type": "rabbitmq",
"version": "3.12"
}
},
{
"type": "update",
"resource": "database",
"details": {
"old_version": "14",
"new_version": "15"
}
}
]
}
The diff is presented in a developer portal as a change summary. A deployment is initiated by calling:
curl -X POST https://platform.example.com/api/v1/deploy \
-H "Content-Type: application/json" \
-d '{
"config": "user-service@v3",
"environment": "production",
"triggered_by": "[email protected]",
"notes": "Enable user email notifications"
}'
The deployment runs asynchronously, and a Deployment object is returned:
{
"id": "deploy-67890",
"config": "user-service@v3",
"environment": "production",
"status": "running",
"started_at": "2024-04-05T13:05:00Z",
"steps": [
{
"name": "build-image",
"status": "success",
"output": "Successfully built image: user-service:v3"
},
{
"name": "apply-k8s-manifests",
"status": "failed",
"error": {
"code": "K8S_APPLY_ERROR",
"message": "Failed to apply manifest: error in deployment: missing service account"
}
}
]
}
The apply-k8s-manifests step fails silently when a ServiceAccount is missing from the manifests/ directory. The error is logged but the deployment continues, leaving the service in a degraded state: the Deployment exists but kubectl get pods -n user-service shows Pending pods with No such file or directory in the mountPath of serviceAccountToken.
A platform’s health is monitored via a health endpoint. The platform exposes a GET /health that returns a structured status:
{
"status": "healthy",
"timestamp": "2024-04-05T13:10:00Z",
"services": {
"provisioning": {
"status": "up",
"latency_ms": 120,
"last_heartbeat": "2024-04-05T13:09:55Z"
},
"configuration": {
"status": "up",
"latency_ms": 85,
"last_heartbeat": "2024-04-05T13:09:58Z"
},
"deployment": {
"status": "degraded",
"latency_ms": 310,
"last_heartbeat": "2024-04-05T13:09:40Z",
"details": {
"queue_length": 37,
"failed_jobs": 12,
"recent_errors": [
{
"code": "K8S_APPLY_ERROR",
"message": "Failed to apply manifest: error in deployment: missing service account",
"count": 7,
"first_seen": "2024-04-05T13:08:00Z"
}
]
}
}
}
}
When deployment status becomes degraded, the platform sends an alert via webhook to https://alerting.example.com/api/v1/alert:
{
"alert": "Deployment service degraded",
"severity": "warning",
"service": "deployment",
"timestamp": "2024-04-05T13:10:00Z",
"details": {
"queue_length": 37,
"failed_jobs": 12,
"recent_errors": [
{
"code": "K8S_APPLY_ERROR",
"message": "Failed to apply manifest: error in deployment: missing service account",
"count": 7
}
]
}
}
The alerting system, built on standard curl, systemctl, and psql, tracks alert history in a alerts table:
CREATE TABLE alerts (
id SERIAL PRIMARY KEY,
alert_name VARCHAR(255) NOT NULL,
severity VARCHAR(50) NOT NULL,
service VARCHAR(100) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
details JSONB
);
INSERT INTO alerts (alert_name, severity, service, timestamp, details)
VALUES (
'Deployment service degraded',
'warning',
'deployment',
'2024-04-05T13:10:00Z',
'{"queue_length": 37, "failed_jobs": 12, "recent_errors": [...]}'::jsonb
);
Each platform service is versioned and released as a product. A new version is created by updating the platform.yaml and pushing a CHANGELOG.md:
## v2.1.0 (2024-04-05)
- Added support for Redis as a cache service
- Introduced automatic environment cleanup after 30 days of inactivity
- Improved error reporting in deployment workflow
- Fixed race condition in configuration registry
The platform uses a version field in platform.yaml to track the service’s current version:
name: user-service
version: 2.1.0
runtime: nodejs
environment: production
When a service is upgraded, the platform compares the current version with the target version and applies migrations. Migrations are defined as a migrations/ directory with SQL and shell scripts:
migrations/
├── 001-add-redis-cache.sql
├── 002-enforce-environment-lifecycle.sh
├── 003-add-secrets-encryption.sql
└── 004-update-registry-index.sh
Each migration is run in sequence. If a migration fails, the system logs the error and halts the upgrade. The upgrade is idempotent: the same migration can be rerun without side effects.
A common failure mode is a missing migration.sql file that is not detected due to incorrect naming. The system expects 001-*.sql, but a file named 01-*.sql is silently ignored. This leads to a partially migrated state: the database schema is updated, but the secrets table is missing. The psql command SELECT * FROM secrets; returns an error relation "secrets" does not exist, which is not surfaced in the deployment logs.
A developer discovers a new infrastructure product by querying the platform’s registry:
curl -X GET 'https://platform.example.com/api/v1/products?category=storage®ion=us-west'
The response lists available products:
[
{
"id": "s3-storage",
"name": "S3-compatible object storage",
"description": "Highly durable, scalable storage for static assets and data files",
"category": "storage",
"region": "us-west",
"api": "https://s3-storage.us-west.example.com",
"documentation": "https://docs.example.com/s3-storage",
"features": [
"versioning",
"lifecycle policies",
"cross-region replication"
],
"pricing": {
"tier": "standard",
"cost_per_gb": 0.021,
"cost_per_request": 0.000005
}
},
{
"id": "block-storage",
"name": "Persistent block storage",
"description": "Low-latency, high-throughput block devices for VMs and databases",
"category": "storage",
"region": "us-west",
"api": "https://block-storage.us-west.example.com",
"documentation": "https://docs.example.com/block-storage",
"features": [
"snapshots",
"encryption at rest",
"auto-expansion"
],
"pricing": {
"tier": "premium",
"cost_per_gb": 0.05,
"cost_per_request": 0.00001
}
}
]
The developer selects s3-storage and creates a new instance via a POST request:
curl -X POST https://platform.example.com/api/v1/products/s3-storage/instantiate \
-H "Content-Type: application/json" \
-d '{
"name": "user-avatars",
"region": "us-west",
"access": "public-read",
"lifecycle": {
"transition_to_cold": 30,
"expiration_days": 365
}
}'
The response includes the new instance’s endpoint and credentials:
{
"instance_id": "s3-avatars-001",
"name": "user-avatars",
"endpoint": "https://s3-avatars-001.us-west.example.com",
"credentials": {
"access_key": "AKIA1234567890",
"secret_key": "s3cR3Tk3yF0rTh3Av4t4rs",
"region": "us-west"
},
"status": "ready",
"created_at": "2024-04-05T13:15:00Z"
}
Secret is not created in the cluster, but the platform only writes the Secret manifest; it does not apply it to the cluster. The Secret must be created in the secrets/ directory, and the kubectl apply -f secrets/ command must run separately.postgres@15, but the cluster runs postgres@14. The platform assumes the version is available, but no Helm chart or Operator is installed to upgrade the cluster. The postgres service is ready, but the application connects to a 14 instance, leading to schema mismatch.ALTER TABLE command fails due to a duplicate column, leading to a duplicate column error that is not logged.platform.yaml file is updated, but the configuration service does not detect the change. The config API returns the old version, and developers deploy against stale configuration. A git commit is required to trigger the change detection, but the commit is not pushed to the main branch.provisioning event is sent, but the deployment service is down. The event is queued, but the deployment service resumes and processes the backlog. However, the provisioning event is processed after the deployment service has already started, leading to a race condition where a service is provisioned but the deployment is missed.Infrastructure as Product is not a set of tools, but a pattern of ownership, feedback, and iteration. It is the difference between infrastructure that works and infrastructure that *delivers value*.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy