No description
Find a file
holzi1005 7bb03148b6
All checks were successful
Build / release (push) Successful in 1m22s
fix app-namespace
2026-09-05 18:31:30 +02:00
.forgejo/workflows fix cgo 2026-09-01 12:42:24 +02:00
.gitignore add tests 2026-03-22 16:18:43 +01:00
go.mod add tests 2026-03-22 16:19:20 +01:00
main.go fix app-namespace 2026-09-05 18:31:30 +02:00
main_test.go fix app-namespace 2026-09-05 18:31:30 +02:00
README.md Fix --- 2026-09-01 19:46:47 +02:00

deploy

A Go CLI tool for deploying Helm charts and raw Kubernetes manifests via kapp and vals.
Supports multiple environments, namespace-scoped deployments, optional CRD deployment and a --dry-run mode that shows what would change without applying anything.


Requirements

Tool Purpose
helm Render Helm charts
vals Resolve secret references in YAML
kapp Apply / delete Kubernetes resources
kubectl Create namespaces

All four tools must be available in $PATH.


Build

go build -o deploy deploy.go

No external Go dependencies only the standard library is used.


Usage

./deploy [flags]
Flag Default Description
--env staging Environment name (folder under --deploy, e.g. dev, staging, production)
--namespace (all) Only process _deployment_<namespace>.json
--deploy deploy Root folder containing the per-environment deployment folders (<deploy>/<env>)
--apps apps Directory holding shared/per-app values files. Use e.g. --apps base for the legacy layout — there is no automatic fallback
--cluster cluster Directory holding cluster-wide values (cluster.yaml/.yml + <env>/)
--dry-run false Show what would change without applying anything

Every values file the tool looks up accepts either a .yaml or a .yml extension (both are checked; if both exist, both are applied, .yaml first).

Examples

# Deploy staging (all deployment files) — looks under ./deploy/staging
./deploy

# Deploy a specific environment — looks under ./deploy/production
./deploy --env production

# Deploy only the monitoring namespace in staging
./deploy --namespace monitoring

# Dry-run for production
./deploy --env production --dry-run

# Dry-run for a single namespace
./deploy --env production --namespace monitoring --dry-run

# Use a custom apps/cluster/deploy folder layout
./deploy --env production --apps charts --cluster cluster-config --deploy environments

Directory structure

.
├── deploy.go
├── cluster/                       # overridable via --cluster
│   ├── cluster.yaml|.yml          # Global cluster values (applies to every env)
│   ├── dev/
│   │   └── *.yaml|*.yml           # Every matching file here is applied for --env dev
│   ├── staging/
│   │   └── *.yaml|*.yml
│   └── production/
│       └── *.yaml|*.yml
├── apps/                          # overridable via --apps (no automatic fallback)
│   ├── default.yaml|.yml          # Applied to every application, in every namespace/env
│   ├── prometheus.yaml|.yml       # Applied only to the app literally named "prometheus"
│   └── prometheus-common.yaml|.yml # Shared file, used via the `base` field (see below)
└── deploy/                        # overridable via --deploy
    ├── dev/
    │   ├── _deployment.json
    │   └── <namespace>/
    │       ├── <name>.yaml|.yml   # Per-chart values override
    │       └── <manifest>.yaml    # One or more raw manifests (see "manifest" field)
    ├── staging/
    │   ├── _deployment.json             # Deployment config (all namespaces)
    │   ├── _deployment_monitoring.json  # Deployment config (monitoring only)
    │   ├── _deployment_storage.json     # Deployment config (storage only)
    │   └── <namespace>/
    │       ├── <name>.yaml|.yml
    │       └── <manifest>.yaml
    └── production/
        ├── _deployment.json
        └── ...

--env staging resolves the environment folder as <deploy>/<env>, i.e. ./deploy/staging by default. This is where _deployment*.json, manifests, and per-namespace value overrides are looked up. The cluster/<env> and kapp app-naming (<env>-<namespace>-<name>) still use just the environment name, independent of --deploy.

Every values file lookup below accepts either extension: if both <name>.yaml and <name>.yml exist, both are applied (.yaml first).

Important asymmetry, by design: cluster/<env>/ is a folder listing — every file inside it is applied to every app in that environment, because it is meant to hold genuinely environment-wide settings. apps/<name> is the opposite: an exact single-file match directly under apps/ (apps/<name>.yaml and/or .yml), never a folder listing. Do not create an apps/<some-name>/ folder to group several apps' files together — it will simply be ignored (the tool only ever looks for apps/<name>.yaml/.yml as a file). This distinction exists because a folder that happened to share one app's exact name once caused a real incident: another app's file got silently merged in, and the wrong container image got deployed.

Values files are loaded in this order (later files take precedence):

  1. <cluster>/cluster.yaml|.yml — global cluster values, applied to every environment
  2. <cluster>/<env>/*.yaml and *.yml — every matching file in the environment's cluster folder, applied alphabetically
  3. <apps>/default.yaml|.yml — defaults applied to every application
  4. <apps>/<name>.yaml|.yml — this app's own file, exact match (or <apps>/<base-field>.yaml|.yml if the app's base field is set)
  5. <deploy>/<env>/<namespace>/<name>.yaml|.yml — per-app, per-namespace override

Migrating from the old layout

  • Cluster values now live under a dedicated cluster/ folder (overridable with --cluster): move ./base/cluster.yml to ./cluster/cluster.yaml (or keep the .yml extension, both work), and split any environment-specific overrides that used to live in ./<env>/cluster.yml into one or more .yaml/.yml files under ./cluster/<env>/.
  • The base/ folder is now called apps/ by default (overridable with --apps). Unlike before, there is no automatic fallback: if you still use the old base/ folder, pass --apps base explicitly, or rename the folder to apps/.
  • Per-chart values stay a single, flat file per app directly under apps/: <apps>/<name>.yaml or .yml. If you previously grouped several apps' files into one folder (e.g. apps/<namespace>/<name>.yaml), flatten them out to apps/<name>.yaml — app names are unique across the whole deployment, so no namespace prefix is needed, and it avoids the exact incident described above.
  • Environment folders (dev, staging, production, …) have moved from the repo root into a deploy/ folder (overridable with --deploy): move ./staging to ./deploy/staging, ./production to ./deploy/production, and so on.
  • Optionally add ./apps/default.yaml (or .yml) for values that should apply to every application (e.g. common labels or annotations), on top of any per-chart file.
  • manifest accepts a single string or an array: "manifest": "cm.yaml" and "manifest": ["cm.yaml"] are both valid and equivalent. (A brief earlier revision required an array only — that restriction has been lifted.)

Deployment files

When --namespace is not set, the tool processes all deployment files in <deploy>/<env>/ in this order:

  1. _deployment.json (if present)
  2. _deployment_<namespace>.json (all matches, alphabetically)

When --namespace is set, only <deploy>/<env>/_deployment_<namespace>.json is processed.


_deployment.json / _deployment_<namespace>.json

{
  "global_manifest": ["imagePullSecret.yaml"],
  "app_k8s_deploy_helm_charts": [
    {
      "namespace": "monitoring",
      "namespaced_manifest": ["monitoring-network-policy.yaml"],
      "applications": [
        {
          "name": "kube-prometheus-stack",
          "chart_ref": "prometheus-community/kube-prometheus-stack",
          "state": "present",
          "version": "58.0.0",
          "deploy_crds": true
        },
        {
          "name": "alertmanager",
          "chart_ref": "prometheus-community/alertmanager",
          "base": "prometheus-common",
          "state": "present"
        },
        {
          "name": "gateway-api",
          "chart_ref": "oci://reg.example.com/charts/gateway-api",
          "deploy_crds": true,
          "manifest": "kapp-config.yaml"
        },
        {
          "name": "gateway-api-crds",
          "state": "present",
          "manifest": [
            "my-configmap.yaml",
            "https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml"
          ]
        },
        {
          "name": "old-app",
          "chart_ref": "my-repo/old-app",
          "state": "absent"
        }
      ]
    }
  ]
}

Application fields

Field Type Required Description
name string Release name and kapp app identifier
state string present to deploy, absent to delete
chart_ref string Helm chart reference. If omitted, Helm is skipped entirely
version string Helm chart version. Omit to use latest
manifest string or array of strings One or more raw manifests, each either a local path (relative to <deploy>/<env>/<namespace>/) or an http(s):// URL (downloaded). Applied in the order listed
deploy_crds bool Pass --include-crds to helm template (default: false)
base string Override per-chart values file. Loads <apps>/<base>.yaml/.yml instead of <apps>/<name>.yaml/.yml

manifest field

manifest accepts either a single string or a list — a single file doesn't need to be wrapped in [...]:

{ "name": "gateway-api", "manifest": "kapp-config.yaml" }
{ "name": "gateway-api", "manifest": ["kapp-config.yaml", "extra-rbac.yaml"] }

Both forms end up applied the same way; a single string is just shorthand for a one-element list.

Each entry is either:

  • a local path, resolved as <deploy>/<env>/<namespace>/<entry> — must exist, or the deploy fails with manifest file not found: ..., or
  • an http:// or https:// URL, which is downloaded directly (a real network request, even during --dry-run — see "Dry-run mode" below) — if the download fails or returns a non-2xx status, the deploy fails with that error.

All listed manifests (local and remote, in any mix) are piped through vals eval and merged into the same kapp deploy, in the order given. Each rendered chunk (the Helm chart's output, and every individual manifest) is automatically separated with a YAML document separator (---) when concatenated into the file handed to kapp — without it, a file combining multiple resources without proper multi-document separation is invalid/ambiguous YAML that kubectl and kapp can silently misparse. You don't need to add --- yourself between manifest files or at the top of a manifest — the tool takes care of it.

base field

By default the tool looks for a file named after the application, <apps>/<name>.yaml or .yml (e.g. ./apps/alertmanager.yaml), directly under apps/ — not a subfolder. Set base to use a different file from the apps directory instead:

{ "name": "alertmanager", "base": "prometheus-common" }

This loads ./apps/prometheus-common.yaml (or .yml) instead of ./apps/alertmanager.yaml. Useful when multiple charts share the same values.

apps/default.yaml / apps/default.yml

Any values in <apps>/default.yaml or <apps>/default.yml are applied to every application, in every namespace and environment, before the per-chart file (<apps>/<name>.yaml/.yml or base-overridden) and before the per-namespace override. Use it for values every chart should share, such as common labels, tolerations, or image pull secrets.

global_manifest / namespaced_manifest

These apply raw manifests per namespace, independent of any single application — typically things every namespace needs regardless of which apps live there, such as an image-pull secret, a default NetworkPolicy, or a ResourceQuota.

Field Scope Type Resolved relative to
global_manifest top-level, applies to every namespace in the file string or array of strings <cluster>/<entry>
namespaced_manifest per namespace group, applies only to that namespace string or array of strings <deploy>/<env>/<namespace>/<entry>

Both accept the same single-string-or-list shorthand and the same http(s):// URL support as the per-app manifest field (see above). For a given namespace, global_manifest and namespaced_manifest entries are combined and deployed together as one additional kapp app, named <env>-<namespace>-namespace-manifests, separate from any application's own kapp app. It's created once per namespace, before that namespace's applications are processed. If a namespace has neither global_manifest nor namespaced_manifest entries, no extra kapp app is created for it.

Image-pull secret example — put this once under <cluster>/imagePullSecret.yaml, resolving the actual credentials via vals (e.g. from Vault), and list it in global_manifest:

apiVersion: v1
kind: Secret
metadata:
  name: regcred
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: ref+vault://secret/registry#/dockerconfigjson
{ "global_manifest": ["imagePullSecret.yaml"], "app_k8s_deploy_helm_charts": [ /* ... */ ] }

That's the whole setup — the tool then creates this Secret in every namespace listed in the file automatically. Reference it from a chart's imagePullSecrets values (or apps/default.yaml if every chart needs it) as - name: regcred.

Behaviour matrix

chart_ref manifest Result
(empty/omitted) Helm chart rendered and deployed via kapp
(one or more) Helm chart + all listed manifests merged into one kapp deploy
(one or more) Only the raw manifests are processed via vals and deployed via kapp
(empty/omitted) Entry is skipped with a warning

kapp app naming

Each application is registered in kapp under the name:

<env>-<namespace>-<name>

Example: staging-monitoring-kube-prometheus-stack

The kapp app itself is always stored in the gitops namespace.


Dry-run mode

--dry-run treats rendering and cluster-mutating steps differently:

  1. Rendering (helm template, vals eval, cat/downloading manifests) runs for real, even in --dry-run. This is local/read-only and non-destructive, and the result is exactly what the diff below is based on; without it, the diff would be computed against an empty file. This includes downloading http(s):// manifest URLs — a genuine network request happens even during a dry-run.
  2. Namespace creation (kubectl create namespace ... | kubectl apply -f -) only printed, never executed, since kubectl apply here really would create the namespace on the cluster.
  3. kapp instead of kapp -y deploy, the tool actually runs:
   kapp deploy --diff-run --diff-changes

This connects to the real cluster and shows exactly which resources would be added, changed or deleted it's read-only against the cluster, so it's safe to run for real, and it's what makes --dry-run actually useful: you see the genuine kapp diff in your terminal, not just a printed command.

kapp delete (for state: absent) and the final kapp -y deploy remain only printed in dry-run, since those steps do mutate the cluster.


vals secret resolution

All YAML passed to kapp runs through vals eval -f - first.
This resolves secret references such as:

password: ref+vault://secret/myapp#/password
token: ref+awssecrets://myapp/token

See the vals documentation for supported backends.


CRD deployment

Setting deploy_crds: true passes --include-crds to helm template.

Note: helm template --include-crds does not render CRDs that live in the crds/ directory of a chart. For reliable CRD deployment it is recommended to helm pull --untar the chart and apply the crds/ directory separately before the main deploy.