Architecture
Dploy is built as a Kubernetes operator plus a thin API. The two communicate only through custom resources — a deliberate separation that keeps the API stateless and prevents it from touching the deployment engine directly.
Overview
Section titled “Overview”Components
Section titled “Components”Dploy API
Section titled “Dploy API”A stateless GoFiber server that:
- authenticates requests via JWT/OIDC (JWKS, cached 15 min),
- serves the catalog by listing
DployTemplateresources, - creates/extends/deletes
DployInstanceClaimresources — one per (owner, template), - serves the embedded web UI.
That is the whole list. The API decides who is asking and what they asked for, then writes it
down; every decision about what happens next — which instance, for how long, whether the quota
allows it — belongs to the operator. Its RBAC says as much: within a single namespace, it reads
DployTemplates, does CRUD on DployInstanceClaims, and reads DployInstances for the Manager
view. It cannot write an instance, and it cannot create a HelmRelease.
Only the claims the deployment lists in FORWARDED_CLAIMS are copied out of the token into
spec.params; the rest never leaves the API server.
Dploy operator
Section titled “Dploy operator”Three controllers built with controller-runtime:
- DployInstanceClaim controller — turns a request into a running environment. It binds a warm pool member (or provisions one on demand), takes ownership of the instance, anchors the TTL at the binding, enforces the per-owner quota, and projects the instance’s status back onto the claim so callers watch one object instead of two.
- DployInstance controller — the core. For each instance it generates an immutable UUID,
creates the workload namespace, renders the connection URL and Helm values, ensures a Flux
source +
HelmRelease, projects the release’s status back, and enforces the TTL. - DployTemplate controller — maintains the warm pool for pool-method templates (creating
unclaimed instances up to
pool.size) and reports occupancy in the template status.
Binding a warm instance
Section titled “Binding a warm instance”Several users can want the last warm instance at the same moment, so the binding is decided by the
Kubernetes API server, not by the operator’s own bookkeeping. The claim controller writes one label —
dploy.dev/claim-uid — onto a candidate instance through a full object update. Optimistic
concurrency does the rest: a claimer holding a stale copy is rejected with a conflict and moves on
to the next candidate. Only once that write lands does the winner apply the rest of the binding
(ownership, owner key, params, TTL), off the contention path.
The instance is then owned by the claim, which is what makes kubectl delete dployinstanceclaim
tear the environment down.
Settling the per-owner quota
Section titled “Settling the per-owner quota”The same burst breaks a naive quota check. Concurrent claims for one owner each pass the check
before any of them binds, and each then sees a different partial set of instances — so a single
count, taken once, lets the cap drift. Instead the operator re-ranks the owner’s instances
(oldest first, keep limit) on every reconcile for the first minute after a binding. The rule is
stable, so once every instance is visible all claims agree on who loses, and the surplus give
theirs back.
One consequence is visible: a claim can go Bound and then Rejected a few seconds later. Past
that window an environment you hold is yours — lowering a quota never reaches back and kills
something already running.
The whole cycle, without the API
Section titled “The whole cycle, without the API”Because every decision lives in the operator, the API is optional. A claim is a complete request:
apiVersion: dploy.dev/v1alpha1kind: DployInstanceClaimmetadata: name: john-doe-webterm namespace: dploy-systemspec: templateRef: webterm owner: john-doe waitForPool: true # park on an empty pool instead of provisioning around it params: email: john@example.com # visible to templates as .Params.emailkubectl apply -f claim.yamlkubectl get dclaim john-doe-webterm -w# NAME TEMPLATE OWNER PHASE INSTANCE URL# john-doe-webterm webterm john-doe Pending# john-doe-webterm webterm john-doe Bound webterm-pool-x9 https://webterm-a1b2c3d4.env.dploy.dev
# Extend: raise the requested lifetime, the operator moves the expirykubectl patch dclaim john-doe-webterm --type=merge -p '{"spec":{"ttlSeconds":7200}}'
# Tear down: the claim owns the instance, which owns the HelmReleasekubectl delete dclaim john-doe-webtermDploy delegates all deployment to Flux. Because Dploy only exposes git/helm chart sources,
the operator always builds a HelmRelease whose chart references a GitRepository or
HelmRepository (OCI Helm registries use a HelmRepository of type oci). The
HelmRelease lives in the instance’s own namespace (so owner references are valid) and
installs into the per-instance workload namespace via targetNamespace.
One source per template, not per instance. Every instance of a template resolves the same
URL at the same revision, so the source is a single <template>-src object owned by the
DployTemplate and shared by all of its instances. A copy per instance meant source-controller
cloned one repository once per environment and re-fetched every copy on its own interval — at
300 environments on a 5m interval, a fetch a second against the forge for content that never
differs. Measured on kind, a 50-environment burst went from 60 identical GitRepository
objects to 1, and the median time to a usable on-demand environment fell from 38.6s to 24.6s.
The trade-off to know: deleting a template garbage-collects the shared source, including from under any claimed instance still running off it. The workload keeps serving — Helm has already installed it — but Flux can no longer upgrade or re-render that release.
The template proves the chart resolves before any instance applies a HelmRelease. Alongside
the shared source, the DployTemplate controller keeps a single HelmChart probe and publishes the
answer as the template’s SourceReady condition. Instances read that condition and refuse to apply
their HelmRelease until it is True.
This exists because the failure it prevents is not local. A chart path that does not exist still
clones fine — the GitRepository goes Ready, and the error only surfaces one level down, when
source-controller tries to resolve the chart for a HelmRelease. Each instance asks for its own
resolution, so a broken template produced one permanently failing HelmChart per instance, each
retried forever. Enough of them and helm-controller stops serving every other template on the
cluster. Probing once per template turns that amplification back into a single object, and puts
the reason on the template where an operator is already looking.
Unknown is deliberately not the same as false. A cluster where Flux has not answered yet — or is
not installed at all — cannot say whether the chart resolves, and refusing to fill a pool on that
suspicion would be worse than the problem. So an unknown verdict holds the HelmRelease back, which
costs nothing, while a known failure also stops the pool from manufacturing members that could
never come up.
OIDC provider
Section titled “OIDC provider”External identity provider (Authentik, Keycloak, Dex, …) that issues JWTs and exposes a JWKS endpoint. See OIDC Providers.
The RBAC boundary
Section titled “The RBAC boundary”The operator/API split is enforced by two service accounts:
| Identity | Can do |
|---|---|
API (namespaced Role) | read dploytemplates and dployinstances; CRUD dployinstanceclaims — nothing else |
Operator (ClusterRole) | CRUD dploy CRs + status/finalizers, helmreleases, Flux sources, namespaces, events |
Only the operator can reach Flux, and only the operator can write a DployInstance. This makes
the trust boundary auditable: a compromised API can at most file environment requests under
identities it can already authenticate — never arbitrary workloads, and never a longer TTL or an
extra environment than the operator grants.
Claim lifecycle
Section titled “Claim lifecycle”Instance lifecycle
Section titled “Instance lifecycle”TTL anchoring
Section titled “TTL anchoring”- The clock starts when the claim binds, not when it is created: the operator records
status.boundAtand derivesstatus.expiresAtfrom it. A request that waits ten minutes for a warm instance does not lose ten minutes of its lifetime. - Extending is a patch, not a verb: raise
spec.ttlSecondsand the expiry moves with it. The operator clamps the result tostatus.maxTTLSeconds— the template’s base TTL plus its full extend budget (ttl.maxExtends × ttl.extendSeconds) — so there is no counter to keep in sync and no way to talk the API into a longer life than the catalog allows. ttlSeconds: -1means unlimited, and is only honored where the template is itself unlimited.- An unclaimed pool member never expires — its clock starts when a claim binds it.
- On expiry the operator deletes the instance and leaves the claim behind as an
Expiredtombstone: the owner can see what happened, and it no longer counts against their quota.
Teardown
Section titled “Teardown”DployInstance carries a finalizer (dploy.dev/instance-cleanup). On deletion the operator
removes the HelmRelease (waiting for Flux to finish the Helm uninstall), then deletes the
workload namespace. The Flux source is left alone — it belongs to the template and is shared
with every other instance of it; it is garbage-collected when the template goes.
Labels & annotations
Section titled “Labels & annotations”labels: dploy.dev/managed: "true" dploy.dev/owner: "john-doe" # sanitized owner dploy.dev/template: "webterm" # source template dploy.dev/instance: "a1b2c3d4" # short UUID dploy.dev/pooled: "true" # warm-pool members only dploy.dev/claim: "john-doe-webterm" # bound claim (informational) dploy.dev/claim-uid: "3f2b1c9a-..." # the binding itselfdploy.dev/claim-uid is the one that matters: writing it is how a claim wins an instance, and
reading it back is how the operator recovers a binding after a restart. It keys on the UID rather
than the name because names can be recycled and UIDs cannot.
Value & URL templating
Section titled “Value & URL templating”Both valuesTemplate and connectionURLTemplate are rendered with Go text/template + sprig.
The data context exposes:
| Field | Description |
|---|---|
.Owner | sanitized owner (empty for unclaimed pool members) |
.UUID | immutable short UUID |
.BaseDomain | OperatorConfig.baseDomain |
.Host | <name>-<uuid>.<baseDomain> |
.URL | resolved connection URL (available to valuesTemplate) |
.Namespace | workload namespace |
.Template | the DployTemplate object |
.Params | request parameters plus the forwarded JWT claims — the only requester-supplied data a template sees |
.Config.Values | OperatorConfig.spec.values |
The rendered values YAML is converted to JSON and set as the HelmRelease’s spec.values.
Authentication flow
Section titled “Authentication flow”Scalability & HA
Section titled “Scalability & HA”- The API is stateless — scale it horizontally behind a load balancer.
- The operator runs a single active replica by default; set
operator.leaderElection=trueto run more than one safely. - All deployment heavy-lifting (sync, health, retries) is handled by Flux’s controllers.