Feature Specification: Kubernetes Deployment Plugin
Behaviour-first spec per Constitution Principle IX. Describe what the system does, not how it's structured. Implementation lives in
./plan.md.
Feature ID: k8s-deployment
Branch: feat/k8s-deployment (merged via #460)
Status: Implemented
Created: 2026-05-05
Last updated: 2026-05-05
Owner: Ever Works Team
Shipped via:
- Platform: #460 (plugin), #462 (deploy service consumer change), #463 (review-pass fixes)
- Templates: ever-works/directory-web-template#734, ever-works/directory-web-minimal-template#2
1. Overview
The Kubernetes (k8s) Deployment Plugin lets users deploy a generated work as a containerised website to any Kubernetes cluster they control, as an alternative to the existing Vercel deployment provider. Users paste a kubeconfig file in the deployment-plugins UI, pick a container registry (defaulting to GitHub Container Registry because the platform already has access to the user's GitHub account), and from then on can publish a work to their cluster from the same "Deploy" surface that already drives Vercel. The plugin appears under Deployment in the Plugins page, exposes the same domain/status/connection-validation behaviours as Vercel, and is selectable per-work either through the dashboard or by declaring deployProvider: k8s in the work's .works/works.yml. Configuration also covers ingress controller detection (nginx, Traefik, and an extensible strategy registry for additional controllers over time).
Two adjacent changes ship as part of this feature so the new plugin doesn't land on a half-paved road:
- Pluggable registry abstraction — the plugin defines a small
RegistryProviderstrategy with agithub(GHCR) default and agenericfallback; new registry kinds can be added without touching the plugin core. deployProviderin.works/works.yml— the works-config layer learns aboutdeployProviderso a work's data repo can declare its target. The same change also applies to Vercel (anydeployProvider: <id>recognised by the deploy facade is accepted).
2. User Scenarios
2.1 Primary scenarios
- Given I am signed in and visit
/en/settings/plugins/deployment, when the page loads, then I see both Vercel and Kubernetes as available deployment providers, with the Kubernetes card showing a "Not configured" state. - Given I open the Kubernetes plugin settings, when I paste a valid
kubeconfigYAML and click Save & verify, then the platform validates the kubeconfig against the cluster API, marks the plugin as configured, and shows the cluster name, server URL, Kubernetes server version, and the ingress controllers it detected (e.g. "nginx, traefik"). - Given I am configuring the Kubernetes plugin, when I do not change the Registry field, then the registry defaults to GitHub Container Registry (
ghcr.io/<my-github-owner>) because the platform already holds my GitHub credentials. - Given my work's website repo is public, when I deploy via GHCR with the default registry settings, then the resulting GHCR image is public, no
imagePullSecretis provisioned, and the Deployment has noimagePullSecrets. - Given my work's website repo is private, when I deploy via GHCR with the default registry settings, then the resulting GHCR image is private, an
imagePullSecretis provisioned in the target namespace using a GHCR read token from the GitHub plugin, and the Deployment references it. - Given I want to override visibility, when I set the registry's
visibilityfield topublicorprivatein the plugin form, then that explicit choice wins over the website-repo-derived default for all subsequent deploys. - Given I have a configured Kubernetes plugin, when I open
/en/plugins, then the Kubernetes entry appears under Deployment with status "Configured" and a working Settings link. - Given my work is set to
deployProvider = 'k8s'(either via the dashboard or in.works/works.yml), when I deploy it, then the platform builds a container image of the website, pushes it to the configured registry (GHCR by default), applies a Deployment, Service and (optional) Ingress to the configured namespace, and reports the resulting URL back through the standard deploy facade. - Given my work's
.works/works.ymldeclaresdeployProvider: k8s, when I import or sync the work, then the platform setswork.deployProvider = 'k8s'accordingly. The same behaviour applies todeployProvider: verceland to any future provider IDs registered through the deploy capability. - Given my work is already deployed to Kubernetes, when I redeploy, then the platform applies an updated image tag with
kubectl rolloutsemantics so the previous version is replaced without downtime, and the deploy facade reportsreadyonce the rollout succeeds. - Given my work has a custom domain (
tools.example.com), when I add it via the Settings → Domains UI on a Kubernetes-deployed work, then the plugin patches the work's Ingress with the new host, returns DNS guidance (CNAME / A record to the cluster's load-balancer ingress), and lets me trigger verification.
2.2 Edge cases & failures
- Given I paste a malformed kubeconfig (invalid YAML or missing
clusters/users/contexts), when I click Save & verify, then the connection result issuccess: falsewith a specific message ("kubeconfig YAML is invalid" or "kubeconfig is missing a current-context") and nothing is persisted server-side that names the broken config. - Given my kubeconfig is valid YAML but the cluster API is unreachable (DNS, firewall, expired client cert), when verification runs, then the plugin returns
success: falsewith the underlying error class (ENOTFOUND,403 Forbidden,x509: certificate expired) — never a stack trace. - Given I deploy to a namespace I cannot create/patch in, when the workflow applies manifests, then the deploy facade transitions the deployment to
errorwith thekubectlerror surfaced verbatim (truncated, no secrets). - Given my registry credentials are wrong, when the build pushes the image, then the deploy run ends with
errorbefore any cluster mutation happens, and the work's previous deployment continues to serve traffic. - Given I switch a work from Vercel to Kubernetes, when I redeploy, then custom-domain rows in the database persist (per
custom-domainsspec FR-8) and are re-synced to the Kubernetes ingress instead of Vercel. - Given my cluster does not have an ingress controller, when I deploy without configuring an ingress host, then the deployment still reaches
readyonce the pod is healthy and the work is reachable in-cluster via theClusterIPService. v1 does not auto-fall-back to aLoadBalancerService (cloud-provider-specific dependency); the deployment result includes akubectl port-forwardhint so users can verify the pod locally. - Given my cluster has multiple ingress controllers (e.g. nginx and Traefik), when validation runs, then the UI lists every detected controller and lets me pick which one this work's Ingress should use; if I never pick, the platform uses the cluster's default IngressClass.
- Given an
IngressClassexists for a controller the plugin doesn't have a strategy for, when validation runs, then that class still appears in the dropdown labelled with the underlying controller name; the plugin falls back to a "generic" strategy that emits a vanillaIngressand skips controller-specific annotations. - Given I delete my kubeconfig from the plugin settings, when I save the empty form, then the plugin transitions to "Not configured", existing in-cluster workloads are untouched, and any work whose
deployProvider = 'k8s'cannot deploy until a new kubeconfig is provided. - Given my GitHub account is not connected, when I select GitHub Container Registry as the registry kind, then the UI tells me to connect GitHub first and links me to the GitHub plugin's auth flow; selecting a different registry kind (Docker Hub, generic) bypasses this requirement.
- Given my
.works/works.ymldeclaresdeployProvider: someUnknownId, when the platform syncs the file, then the import surfaces a validation error pointing at the unknown id, the work'sdeployProvideris left unchanged, and a clear message tells the user which provider IDs are available.
3. Functional Requirements
- FR-1 The system MUST register a deployment plugin with
id = 'k8s',name = 'Kubernetes',category = 'deployment',capabilities = ['deployment'], discoverable via the existing capability-based plugin facade with no hardcoded references in API or web code. - FR-2 The plugin MUST expose a settings schema with at minimum:
kubeconfig(secret, user-scoped,x-widget: textarea),namespace,registry(a discriminated object — see FR-13), and OPTIONALkubeContext,ingressClass,ingressHost,tlsIssuer,replicas. - FR-3 Saving plugin settings MUST run
validateConnection()against the cluster (a benign read such asgetServerVersion) and reject save when the cluster cannot be reached or rejects the credentials. - FR-4 The Kubernetes plugin MUST appear automatically alongside Vercel on
/en/settings/plugins/deploymentand on/en/pluginsunder Deployment, with no plugin-id hardcoded in the web app. - FR-5 The platform MUST support
work.deployProvider = 'k8s'end-to-end: the Deploy facade resolves the plugin, the deploy controller orchestrates the workflow, and the deploy verifier polls cluster state. - FR-6 Deploying a work MUST (a) build a container image of the website, (b) push it to the configured registry with a deterministic tag (work-id + git SHA), (c) apply a
Deploymentwith the new image, aService, and (wheningressHostis set) anIngressto the configured namespace, and (d) wait forkubectl rollout statusbefore reportingready. - FR-7 Redeploys MUST be a rolling update of the existing
Deployment(not a delete-and-recreate); previous ReplicaSets MUST remain in the rollout history so users cankubectl rollout undoout-of-band. - FR-8 The plugin MUST implement
addDomain,removeDomain,verifyDomainagainst the work's Ingress, returning provider DNS guidance compatible with thecustom-domainsfeature. - FR-9 The plugin MUST surface deployment status via
getDeploymentStatus(deploymentId, token)derived from the Deployment's rollout state (available→ready,progressing→building/deploying,failed→error). - FR-10 All cluster credentials and registry passwords MUST be stored via the existing
x-secretplugin-settings store and MUST NOT appear in logs, activity-log entries, or API responses. - FR-11 The plugin MUST NOT be
defaultForCapabilities: ['deployment'](Vercel keeps that role); selecting Kubernetes is an explicit per-work choice. - FR-12
validateConnection()MUST return cluster name, server URL, and server version on success so the UI can show "Connected to clusterprod-eu(v1.30.4) athttps://api.prod-eu.example.com". - FR-13 The plugin MUST support a pluggable registry abstraction with at least three built-in kinds for v1:
github(GHCR, default — auto-configured from the user's connected GitHub account),dockerhub(username + PAT), andgeneric(server URL + username + password). New registry kinds MUST be addable by registering a strategy without modifying the plugin's deploy/apply code. - FR-14 When the registry kind is
github, the system MUST reuse the user's existing GitHub OAuth or PAT (already stored by the GitHub plugin) to authenticatedocker pushin the workflow runner. When GitHub is not connected, saving the plugin settings MUST surface a clear "connect GitHub first" message and link to the GitHub plugin's auth flow. - FR-15
validateConnection()MUST detectIngressClassresources in the cluster and return the list (name + controller string) so the UI can populate an ingress-class dropdown. The system MUST mark which detected classes have a built-in strategy (nginx,traefik) versus the generic fallback. - FR-16 The plugin MUST expose an extensible
IngressStrategyinterface so a contributor can add a new controller (e.g.haproxy,gloo,envoy-gateway) by registering a strategy module. Adding a strategy MUST NOT require changes tomanifest.rendererordeploy(). - FR-17 The works-config layer MUST recognise an optional
deployProviderfield in.works/works.yml. Importing or syncing a work whose config carriesdeployProvider: <id>MUST setwork.deployProvideraccordingly, validated againstDeployFacadeService.getAvailableProviders(). This applies to all deploy providers (k8s, vercel, future) — there are no hardcoded provider ids in the works-config layer. - FR-18 When the work also has a dashboard-set
deployProvider, the works-config value MUST take precedence on sync so the data repo remains the source of truth (Constitution Principle III). A conflict event MUST be written to the activity log when the two disagree at sync time. - FR-19 Adding
deployProvidersupport to the works-config layer MUST be a single change reused by Vercel and Kubernetes — no plugin-specific branches. - FR-20 When the registry kind is
github, the image's default visibility MUST mirror the visibility of the work's website repository (public website repo → public image; private website repo → private image). The user MAY override the default to eitherpublicorprivatein the registry sub-form. The platform MUST read the website repo's visibility through the existing GitHub plugin (no new GitHub-API calls outside the plugin). - FR-21 When a private image is required, the platform MUST provision an
imagePullSecretin the target namespace and reference it from theDeployment.spec.template.spec.imagePullSecrets. When the image is public, no pull secret is created and the Deployment carries noimagePullSecrets. - FR-22 Plugin settings (kubeconfig, registry config, namespace, ingress preferences, replicas) MUST be persisted in the existing
plugin_settingsstore withx-scope: 'user', exactly like the Vercel plugin'sapiToken. There is no separate Kubernetes-specific persistence layer. - FR-23 Each user/tenant MAY configure at most one Kubernetes cluster through the plugin. All works owned by that user that have
deployProvider = 'k8s'deploy to the same cluster, into namespaces derived from the plugin'snamespacesetting (defaultever-works). Per-work cluster overrides are explicitly out of scope for v1. - FR-24 Ingress detection MUST run on every save (no per-cluster caching in v1) so the user always sees the live state of the cluster. If profiling shows
validateConnectionexceeding the §4 P95 budget of 3 s, a per-(userId, clusterFingerprint)cache MAY be added in a follow-up — but it is not required for v1.
4. Non-Functional Requirements
- Performance:
validateConnectionP95 < 3 s against a reachable cluster; full deploy P95 < 6 min for a small site (image build dominates); domain operations P95 < 2 s. - Reliability: a failed deploy MUST leave the previous rollout serving traffic; the plugin MUST never partially apply manifests (use server-side apply or atomic apply order: image push → Deployment → Service → Ingress, with rollback if any step fails after Deployment is created).
- Security & privacy: kubeconfig and registry passwords are
x-secret; the plugin MUST scrub credentials from any error message before raising it; the workflow runner uses short-lived in-process kubeconfig files (mktemp) and never persists them on disk beyond the run. - Observability: activity-log events
deployment_started,deployment_image_pushed,deployment_applied,deployment_succeeded,deployment_failedcarryprovider = 'k8s',workId,namespace, and (truncated) cluster server URL. Sentry tags:provider:k8s,clusterId(sha256 of server URL). - Compatibility: requires Kubernetes server ≥ 1.27 (current stable -2). Plugin SDK ≥ the version that ships
IDeploymentPlugin(already shipped). No breaking changes to existing plugins or to the Vercel plugin.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
KubernetesPlugin | The plugin class implementing IPlugin + IDeploymentPlugin for id = 'k8s'. |
kubeconfig | The standard YAML file holding cluster, user, and context entries; stored as a single secret string in plugin settings. |
KubernetesSettings | Plugin settings shape: { kubeconfig, namespace, kubeContext?, registry, ingressClass?, ingressHost?, tlsIssuer?, replicas? }. |
RegistryConfig | Discriminated union — { kind: 'github', owner?, visibility?: 'auto' | 'public' | 'private' } | { kind: 'dockerhub', username, password } | { kind: 'generic', server, username, password }. The plugin's deploy code knows only the abstraction. visibility defaults to 'auto' which mirrors the website repo's visibility (per FR-20). |
RegistryProvider | Strategy that maps a RegistryConfig to (a) the image reference base (ghcr.io/<owner>, docker.io/<user>, …) and (b) the docker login / pull-secret credentials at deploy time. |
IngressStrategy | Strategy that takes manifest inputs and emits Ingress annotations specific to a controller (nginx, traefik, generic). Registered in a controller→strategy map. |
IngressClassDescriptor | What validateConnection reports per detected class: { name, controller, hasStrategy: boolean }. |
| Manifest bundle | The set of resources the plugin applies per work: one Deployment, one Service, optional Ingress, optional imagePullSecret. |
| Cluster identity | The (server URL, cluster CA fingerprint) pair used to identify a cluster for activity-log entries without leaking credentials. |
| Image tag | {registry-base}/{work-slug}:{shortSha} — deterministic so redeploys are diffable. |
| Rollout state | The Deployment's status.conditions mapped to DeploymentResult.status. |
deployProvider (works-config) | Optional field in .works/works.yml that declares which deployment plugin id (k8s, vercel, …) the work should use. |
6. Out of Scope
- Helm chart authoring or third-party Helm releases. The plugin renders its own minimal manifests; users wanting Helm should use the Helm-based template (separate feature).
- Full GitOps (Argo CD / Flux). The plugin pushes manifests imperatively via kubectl/server-side apply; a future GitOps mode is a separate spec.
- Cluster provisioning (creating the cluster, setting up cert-manager, ingress controller). The plugin assumes a pre-existing, working cluster.
- Multi-cluster failover / blue-green between clusters. One work targets one cluster.
- In-cluster databases or stateful workloads. The website is stateless; databases stay external.
- Bring-your-own Dockerfile. The website template owns the Dockerfile; users who need custom builds modify the template (same pattern as Vercel).
- Helm/Kustomize as the manifest renderer for v1; raw YAML rendering only.
- Cloud-provider-specific registries (ECR, GCR, ACR) in v1 — they are post-v1 and slot into the same
RegistryProviderstrategy. - Ingress strategies beyond
nginx,traefik, and the generic fallback in v1 — the strategy registry is the extension point. - A platform-hosted shared image registry. v1 always uses the user's own registry (with GHCR as the zero-config default).
7. Acceptance Criteria
- Discovery: a user opening
/en/settings/plugins/deploymentsees both Vercel and Kubernetes; opening/en/pluginsshows Kubernetes under Deployment. - Configure & verify: pasting a valid kubeconfig (against any reachable cluster, e.g. kind, k3d, EKS) shows cluster name, server URL, server version, and the list of detected ingress controllers on save.
- Configure & reject: an invalid kubeconfig produces a specific, non-stack-trace error and does not mark the plugin as configured.
- GHCR default: with GitHub already connected, leaving the registry field at default deploys an image to
ghcr.io/<owner>/<work-slug>:<sha>. The image visibility (and whether animagePullSecretis provisioned) follows the website repo's visibility. - GHCR visibility override: setting
registry.visibility = 'private'on a public-website work produces a private image with a pull secret; setting'public'on a private-website work produces a public image with no pull secret. - One cluster per user: a user with two works that both have
deployProvider = 'k8s'deploys both to the same cluster (the user's stored kubeconfig). Per-work cluster choice is not exposed. - Registry switch: switching the registry kind to Docker Hub or generic uses the supplied credentials end-to-end with no GHCR fallback.
- Ingress detection: clusters with nginx and Traefik installed list both in the dropdown; selecting Traefik produces a Traefik-annotated Ingress; selecting an unknown class falls back to the generic strategy.
- Deploy: a work with
deployProvider = 'k8s'builds an image, pushes it, applies a Deployment + Service (+ Ingress ifingressHostset), and reachesreadyonce rollout completes. - Redeploy: a second deploy is a rolling update of the same Deployment; old ReplicaSets remain in history.
- Failure path: a wrong registry password fails before any manifest is applied; previous rollout still serves traffic.
- Custom domains: adding
tools.example.compatches the Ingress and returns DNS guidance; verify endpoint flipsverifiedonce DNS resolves to the cluster. - Provider switch: switching a work from Vercel to Kubernetes preserves
custom_domainsrows and re-syncs them to the Ingress. - works-config: a
.works/works.ymlcontainingdeployProvider: k8sresults inwork.deployProvider = 'k8s'after sync; same forvercel; an unknown id surfaces a validation error and leaves the existing value untouched. - Secret hygiene: kubeconfig and registry password are never returned by
GET /api/plugins/k8s/settings; activity-log entries contain only the cluster fingerprint, not the kubeconfig. - Tests: every functional requirement has a unit or integration test (FR-1…FR-19 mapped in
tasks.md). The plugin package's Vitest suite passes; the API e2e suite covers the deploy controller against a stubbed plugin.
8. Open Questions
All prior questions have been resolved by product input and are recorded here as decisions:
- D1 — Image build location: build runs in a GitHub Actions workflow inside the user's website-template fork (mirrors Vercel). No Trigger.dev / Ever-Works compute is used. (FR-6.)
- D2 — Default registry: GitHub Container Registry (
ghcr.io/<owner>/<work-slug>) is the zero-config default, reusing the user's connected GitHub account. The registry is pluggable; Docker Hub and a generic kind ship in v1, cloud-provider registries are post-v1. No platform-hosted registry. (FR-13, FR-14.) - D3 — Ingress detection:
validateConnection()actively probesIngressClassresources and reports them in the response on every save. Strategies fornginxandtraefikship in v1, with a generic fallback for unknown controllers and a strategy-registry for adding more over time. No caching in v1 (cluster round-trip is fast); a per-(userId, clusterFingerprint)cache is a follow-up if needed. (FR-15, FR-16, FR-24.) - D4 — Provider visibility & works-config: the plugin's
visibilityis'user-only'(matches Vercel). Independently of visibility, both the dashboard and.works/works.ymlcan setdeployProvider; the works-config layer is extended generically so this works for k8s, Vercel, and any future deploy plugin. (FR-17, FR-18, FR-19.) - D5 — GHCR image visibility: defaults to mirror the website repo's visibility — public repo → public image (no pull secret); private repo → private image (pull secret provisioned). The user can override by setting the registry sub-form's
visibilityfield explicitly. The platform reads the website repo's visibility through the existing GitHub plugin. (FR-20, FR-21.) - D6 — Cluster scope: one cluster per user/tenant. Plugin settings live in the existing
plugin_settingsstore withx-scope: 'user'(same shape as Vercel'sapiToken). Per-work cluster overrides are out of scope. (FR-22, FR-23.)
No open questions remain at the time of writing. New questions surfaced during implementation should be appended below with the [NEEDS CLARIFICATION: …] marker so they can be grepped before merge.
9. Constitution Gates
- I — Plugin-first: the integration ships as a new plugin in
packages/plugins/k8s/; no core code knows about Kubernetes specifics. - II — Capability-driven: routing goes through
DeployFacadeService.getByCapability('deployment'); noif (provider === 'k8s')branches in API or web code. - III — Source-of-truth repos: deployment manifests are derived from work state at deploy time, not stored as a separate source-of-truth repo. The website source repo remains the source of truth.
- IV — Trigger.dev: deployment polling continues to use
DeploymentVerifierService(existing pattern); no new always-on workers. - V — Forward-only migrations: no schema changes — the plugin reuses
plugin_settings,custom_domains, andworks.deployProvider(already a free-form string column). - VI — Tests: every FR maps to at least one test (see
tasks.mdPhase 7). - VII — Secret hygiene:
kubeconfigand the registry sub-form'spassword(Docker Hub / generic) arex-secret; scrubbing is enforced in error mapping. - VIII — Plugin counts:
docs/plugin-system/built-in-plugins.mdis updated in the same PR (T6). - IX — Behaviour-first: this spec describes user-observable behaviour; manifest layout, kubectl invocation, etc. live in
plan.md. - X — Backwards-compat:
IDeploymentPluginis unchanged; Vercel still works; existing works are unaffected.
10. References
- Plan:
./plan.md - Tasks:
./tasks.md - Sibling plugin:
packages/plugins/vercel/ - Capability contract:
packages/plugin/src/contracts/capabilities/deployment.interface.ts - Deploy facade:
packages/agent/src/facades/deploy.facade.ts - Deploy service:
apps/api/src/plugins-capabilities/deploy/deploy.service.ts - Web settings UI:
apps/web/src/app/[locale]/(dashboard)/settings/plugins/[category]/page.tsx - Related feature:
../custom-domains/spec.md - User-facing doc:
../../../features/k8s-deployment.md