Teams deploying containerized applications face a recurring dilemma: the infrastructure they target changes over time, but the applications themselves don't. A web service with a background worker, a database, and a public endpoint is fundamentally the same workload whether it runs on Kubernetes, a single Docker host, or a managed container platform like Azure Container Apps.
Today, Pulumi programs are tightly coupled to the target runtime. Switching from Docker Compose to Kubernetes — or from Kubernetes to a managed platform — means rewriting infrastructure code from scratch. Even within a single runtime, teams duplicate boilerplate across projects: namespace creation, image pull secrets, ingress resources, TLS certificates, health check configuration.
There is no standard way to say "here is my application, deploy it wherever makes sense" in Pulumi.
Opsen is a set of TypeScript libraries for Pulumi that separate what you deploy from where you deploy it.
You describe your application once — its processes, ports, environment variables, health checks, volumes, and public endpoints. Then you choose a runtime deployer (Kubernetes, Docker, Azure Container Apps, Azure Web Apps) and Opsen translates your description into the correct Pulumi resources for that target.
The goal is not to hide the underlying platform. Runtime-specific tuning (K8s resource requests, Docker memory limits, ACA scaling rules) is always available via optional typed extensions. The goal is to make the common case trivial and the platform switch painless.
We built Opsen to solve our own problem. We run a small fleet of services across Kubernetes clusters and occasionally need to spin up the same workloads on a single Docker host for development, or evaluate moving services to a managed platform. We were maintaining three separate sets of Pulumi code for what was conceptually the same deployment.
Opsen grew out of extracting the generic parts of our internal platform tooling into something reusable. The internal code remains a thin consumer that adds organization-specific resource providers (managed databases, object storage, email) on top of Opsen's workload primitives.
The typical Opsen user is a small-to-medium team that:
- Deploys containerized services with Pulumi (not Terraform, not Helm)
- Wants to target multiple runtimes without duplicating infrastructure code
- Needs a lightweight application model — not a full PaaS, just enough structure to avoid copy-pasting Deployment/Service/Ingress YAML equivalents across projects
- Values type safety and wants their IDE to tell them what's available
Describe, don't orchestrate. A workload is a data structure, not a sequence of imperative steps. The runtime deployer decides how to realize it.
Runtime-specific is opt-in. The core workload type has no mention of Kubernetes, Docker, or Azure. Each runtime adds optional fields (_k8s, _docker, _aca, _az) that are fully typed but never required.
One package per runtime. Install only what you use. @opsen/k8s doesn't pull in Docker dependencies. Runtime packages never depend on each other.
Pulumi-native. Opsen doesn't wrap Pulumi or introduce its own state management. It produces standard Pulumi resources. You can mix Opsen-deployed workloads with hand-written Pulumi code in the same program.
Facts for cross-stack state. Complex setups often span multiple Pulumi stacks (networking in one, platform in another, workloads in a third). @opsen/base-ops provides a typed facts system for passing structured state between stacks without ad-hoc output parsing. The FactStore abstraction (FactStoreReader / FactStoreWriter) decouples fact storage from Pulumi StackReferences, so teams can share facts via any backend (Vault, Azure Key Vault, S3, etc.).
- Not a PaaS. There is no control plane, no CLI, no dashboard. Opsen is a library you use inside Pulumi programs.
- Not a Helm replacement. Opsen doesn't template YAML. It creates Pulumi resources programmatically.
- Not a multi-cloud abstraction layer. It doesn't try to make AWS and Azure look the same. It makes workload deployment look the same across container runtimes.
- Not opinionated about CI/CD. Opsen doesn't know or care how you run
pulumi up.
Low-level primitives for multi-stack Pulumi projects:
- Facts — typed data objects (kind + metadata + spec) that flow between stacks. Think of them as lightweight CRDs for your infrastructure state.
- Facts Pool — indexed collection with O(1) lookup by kind+name and label-based filtering.
- FactStore — storage-agnostic abstraction for reading and writing facts.
FactStoreReaderandFactStoreWriterare Pulumi-free interfaces;PulumiFactStoreprovides the StackReference-backed implementation. Custom implementations can target any backend (Vault, Key Vault, S3, a database). - Deployer — sequential module execution pipeline that accumulates facts as side effects. Accepts both legacy
configStacks(StackReference-based) andfactSources/factSink(FactStore-based) for reading and writing facts. - Config — cross-stack configuration reader and merger.
This package has no opinion about workloads or containers. It's useful on its own for any multi-stack Pulumi project that needs structured state sharing.
The application model:
- Workload — processes, endpoints, volumes, files, health checks, environment variables. Parameterized by a runtime type for platform-specific extensions.
- RuntimeDeployer — interface that each runtime implements. Takes a Workload, returns DeployedWorkload (with resolved endpoints and process handles).
- WorkloadModule — bridges RuntimeDeployer into the
@opsen/base-opsdeployer pipeline, exposing deployment results as facts.
Runtime-specific types (AzureRuntime, DockerRuntime, KubernetesRuntime) live in their respective packages, not in platform. Platform is standalone with no knowledge of specific runtimes.
Kubernetes RuntimeDeployer. For each workload:
- Creates a Deployment per process
- Creates Services and Ingress resources for endpoints
- Manages PersistentVolumeClaims for volumes
- Creates ConfigMaps for injected files
- Handles image pull secrets and namespace provisioning
- Maps health checks to K8s probes
Docker single-host RuntimeDeployer. For each workload:
- Creates a Docker network for inter-container communication
- Creates a Container per process (with optional scaling via instance suffix)
- Creates Docker Volumes for persistent storage
- Injects files via container uploads
- Maps health checks to Docker HEALTHCHECK
- Deploys a Caddy reverse proxy for endpoints that need ingress (auto-TLS via Let's Encrypt)
Designed for development environments and small single-server deployments.
Azure runtime deployers and infrastructure deployers. All deployers extend AzureDeployer base class which manages a shared Azure Native provider and provides options() helper for provider injection.
Runtime deployers (implement RuntimeDeployer):
AzureRuntimeDeployer— maps workload processes to Azure Container Apps with ACA native ingress, CORS, secret volumes, and registry credentialsAzureWebAppRuntimeDeployer— maps workload processes to Azure Web Apps for Containers with Key Vault secret references, Azure Files mounts, and Application Insights
Infrastructure deployers (extend AzureDeployer):
ContainerAppDeployer/WebAppDeployer— lower-level deployers used internally by the runtime deployersAppGatewayDeployer— Application Gateway with WAF_v2 SKU, public IP, auto-scalingCertRenewalFunctionDeployer— Azure Function (Consumption plan) for automated ACME certificate renewalCertRenewalJobDeployer— Container App Job alternative for certificate renewal
App Gateway WAF integration:
Endpoints with _az.waf: true are automatically routed through App Gateway with ACME TLS certificates via Key Vault. Six dynamic providers (listener, pool, settings, rule, probe, ssl-cert) manage App Gateway sub-resources with etag-based optimistic concurrency. Sub-resources are chained with dependsOn to prevent concurrent PUT failures.
Building blocks — pure data-transform functions (buildContainerAppSpec, buildWebAppSpec, buildAppGatewayEntries) that can be used independently without the full deployer.
Reusable Kubernetes cluster components for common infrastructure needs:
- cert-manager — TLS certificate management with configurable issuers
- ingress-nginx — NGINX ingress controller
- external-dns — Automatic DNS record management (Cloudflare)
- Prometheus stack — Monitoring with Prometheus, Grafana, and Alertmanager
- Loki — Log aggregation
- OAuth2 proxy — Authentication proxy
- MinIO — S3-compatible object storage operator
- Kafka — Kafka operator (Strimzi)
Provides a KubernetesOpsDeployer that orchestrates these components together, built on the @opsen/base-ops deployer pipeline.
ACME certificate renewal for Azure Key Vault + App Gateway. Discovers opsen-managed certificates in Key Vault via tags, issues/renews via Let's Encrypt DNS-01, and updates Key Vault secrets with PFX. Ships as both a CLI and a pre-built Azure Function zip artifact for zero-config deployment.
HashiCorp Vault KV v2 backend for FactStore. Stores facts as JSON in Vault secrets with path-based naming. Supports owner-scoped stale cleanup.
Azure Key Vault backend for FactStore. Stores facts as JSON in Key Vault secrets with {owner}--{kind}--{name} naming. Owner prefix enables scoped stale cleanup without a manifest — on write, secrets matching the owner prefix but not in the current write set are deleted.
SSH-based Docker Compose deployer with MirrorState file sync. Deploys Compose projects to remote hosts over SSH, syncing configuration files and managing lifecycle. Includes Pulumi dynamic providers for PostgreSQL databases, internal DNS records, and readiness checks.
Pulumi dynamic providers for PowerDNS authoritative server and Recursor. Manages DNS zones and forward zones via the PowerDNS API.
VM deploy agent — a Go binary deployed via Pulumi installer (AgentInstaller). Provides HTTP API endpoints for managing Docker Compose projects, Caddy ingress routes, and PostgreSQL databases/roles on target VMs. Uses mTLS authentication with client certificates. Runs as a systemd service.
The Workload type is generic over a runtime parameter. Each runtime defines optional fields at four levels:
| Level | K8s field | Docker field | Azure field |
|---|---|---|---|
| Workload | _k8s.resources |
_docker.restart, memoryMb, cpus |
_aca.workloadProfileName |
| Process | _k8s.resources |
_docker.restart, memoryMb, cpus, networkMode |
_aca.minReplicas, maxReplicas, cpuCores, memoryGi |
| Volume | _k8s.storage (class, size) |
_docker.driver, driverOpts |
_aca.storageType, storageName |
| Ingress | _k8s (empty for now) |
_docker.acmeEmail |
_aca.customDomains, _az.waf (route through App Gateway) |
These fields are invisible when writing runtime-agnostic code and fully type-checked when targeting a specific runtime.
- More runtimes. AWS ECS/Fargate and Google Cloud Run are natural next targets.
- Resource providers. The internal platform that consumes Opsen has resource providers for managed databases, object storage, and email. A generic resource provider interface in
@opsen/platformcould make these shareable. - Validation. Pre-deployment validation that catches misconfigurations (e.g., ingress endpoint without ports, volume mount without volume definition) before
pulumi up. - More FactStore implementations. HashiCorp Vault (
@opsen/vault-fact-store) and Azure Key Vault (@opsen/azure-fact-store) are implemented. AWS Secrets Manager and other backends are natural extensions.