End-to-end local deployment of FleetDM — server, MySQL and Valkey (Redis-compatible) — onto a minikube cluster, orchestrated by Helmfile and driven via thin Task wrappers.
The full stack is described declaratively in a single
helmfile.yaml.gotmpl. Release ordering,
namespaces, values files and the schema-bootstrap Jobs are all part of
that spec — no custom imperative install script.
The deployment follows FleetDM's Option B — install MySQL and Redis/Valkey separately and uses official upstream Helm charts for every off-the-shelf component:
| Component | Source |
|---|---|
| MySQL | Local chart wrapping the official mysql:8.4 Docker image — see charts/mysql/ |
| Valkey | valkey-io/valkey-helm |
| Fleet | fleetdm/fleet |
| ingress-nginx | kubernetes/ingress-nginx |
| cert-manager | jetstack/cert-manager |
Why a local chart for MySQL? Oracle's MySQL Operator only provisions
InnoDBCluster resources with Group Replication enabled, which rejects
Fleet's host_mdm_windows_profiles migration (it DROP PRIMARY KEYs
without a paired ADD PRIMARY KEY in the same statement). The thin
charts/mysql/ wrapper avoids that without
giving up the upstream image.
TLS is mandatory, not optional: Fleet's UI bundle persists the session
token in a __Host-token cookie marked Secure, which browsers refuse
to set on plain HTTP. cert-manager + a local self-signed root CA
(charts/cert-manager-issuers/) make
HTTPS work offline on *.nip.io without external ACME — see
TLS / cert-manager below.
- Highlights
- Repository layout
- Prerequisites
- Quickstart
- kubectl setup
- Task reference
- Configuration cheatsheet
- How-to
- CI / release pipeline
- Local pre-commit hooks
- Troubleshooting
- Component versions
- Further reading
- License
| Document | Scope |
|---|---|
docs/ARCHITECTURE.md |
AWS Well-Architected solution design for Company Inc. (cloud accounts, VPC, EKS, CI/CD, MongoDB Atlas) |
deploy/values/README.md |
Release graph + per-release values walkthrough for the local stack |
charts/bootstrap/README.md |
Namespaces + shared secrets bootstrap chart |
charts/mysql/README.md |
Standalone MySQL 8.x chart; why not Oracle's operator |
charts/fleet-db-init/README.md |
init-db + prepare-db Helm post-install Jobs |
charts/cert-manager-issuers/README.md |
Self-signed root CA + fleet-ca ClusterIssuer chain |
- One-command cluster + stack:
task cluster:create && task fleet:install(fleet:installis a wrapper forhelmfile sync) - Standalone MySQL 8.x via a thin local chart on the official Docker image — no Group Replication, no operator
- Fleet user/database bootstrapped by an
init-dbJob, thenfleet prepare dbruns as a follow-upprepare-dbJob — both as Helm post-install hooks of thefleet-db-initchart - HTTPS by default: cert-manager + local self-signed root CA mint a
leaf cert into the ingress automatically;
task fleet:trust-caimports the root into the host keychain so the browser shows a green lock (idempotent; skipped onCI=true) - ingress-nginx admission webhook serving cert is also provisioned by
cert-manager (via the chart's
certManager.enabled=trueswitch) — avoids the chart's default racypost-installpatch job that can leave theValidatingWebhookConfigurationwith an emptycaBundle - Exposure without
/etc/hosts: ingress on anip.iohostname derived from the minikube IP, plus NodePort and port-forward alternatives - End-to-end
task fleet:verifyhealth checks for pods, MySQL, Valkey, Fleet HTTP API and ingress - CI: yamllint + chart-testing on PRs; semantic-release + chart-releaser
on merge to
main(publishes any chart undercharts/togh-pages) - Pre-commit hooks (lefthook): yamllint,
helm lint,helm template, Conventional Commits enforcement
.
├── helmfile.yaml.gotmpl # Declarative spec: releases, ordering, hooks
├── Taskfile.yaml # Root task entrypoints + global defaults
├── tasks/
│ ├── cluster.task.yaml # cluster:create / start / stop / delete
│ ├── chart.task.yaml # chart:install / lint / uninstall (ad-hoc)
│ └── fleet.task.yaml # thin wrappers around `helmfile sync` etc.
├── charts/ # Local Helm charts
│ ├── bootstrap/ # Namespaces + shared secrets (JWT, mysql-creds)
│ ├── mysql/ # Standalone MySQL 8.x (no operator)
│ ├── fleet-db-init/ # init-db + prepare-db as Helm post-install Jobs
│ └── cert-manager-issuers/ # SelfSigned -> root CA -> fleet-ca ClusterIssuer
├── deploy/
│ └── fleet/ # Per-release values files for upstream charts
├── .github/workflows/
│ ├── ci.yaml # PR: yamllint + helmfile lint + chart-testing
│ └── release.yaml # semantic-release + chart-releaser
├── lefthook.yml # Local pre-commit hooks (incl. helmfile lint)
├── .yamllint.yaml # yamllint config
├── ct.yaml # chart-testing config
└── release.config.cjs # semantic-release config
Platform note: end-to-end tested on macOS only (Apple Silicon + Intel, Docker Desktop and
vfkitdrivers). The stack is expected to work on Linux as well — task definitions and Helm charts are platform-agnostic — but the Linux path (e.g.minikubewithkvm2/dockerdriver,update-ca-certificatesinfleet:trust-ca) has not been exercised yet and needs verification. Reports / PRs from a Linux run welcome.
| Tool | Purpose | Install |
|---|---|---|
minikube |
Local Kubernetes cluster | brew install minikube |
kubectl |
Cluster CLI | brew install kubectl |
helm |
Chart install / lint / packaging | brew install helm |
helmfile |
Declarative orchestration of helm releases | brew install helmfile |
task |
Task runner | brew install go-task/tap/go-task |
openssl |
JWT key generation | preinstalled on macOS/Linux |
curl |
Health checks | preinstalled |
docker |
Container runtime for the docker driver |
Docker Desktop / OrbStack / Colima |
vfkit |
Apple Virtualization driver (macOS only) | brew install cfergeau/crc/vfkit |
| Tool | Used for |
|---|---|
yamllint |
Pre-commit YAML linting (lefthook) |
lefthook |
Git pre-commit hooks (brew install lefthook) |
fleetctl |
Building Fleet enrollment packages for agents |
Comfortable minimum for the full stack: 4 vCPU / 6 GiB RAM / 30 GiB disk —
these are the defaults in Taskfile.yaml. Override via CLUSTER_* env
vars when needed (e.g. CLUSTER_MEMORY=8192).
# 1. Create a local cluster (default driver = docker)
task cluster:create
# 2. Deploy MySQL + Valkey + cert-manager + ingress-nginx + Fleet
task fleet:install
# 3. Expose via nip.io — no /etc/hosts edits, HTTPS by default
# The browser will warn about the self-signed cert on first visit —
# just click through to accept it (or run `task fleet:trust-ca` for
# a green lock; that one is opt-in and prompts for sudo).
task fleet:expose
# 4. macOS + docker driver only: open a SECOND terminal and run the tunnel.
# This routes the cluster's LoadBalancer IP onto 127.0.0.1 so the
# ingress (and `task fleet:verify`'s ingress check) is reachable
# from the host. Keep it running in the background — Ctrl+C tears
# the tunnel down. On Linux / vfkit driver the minikube IP is already
# host-routable, so this step is unnecessary.
sudo true # cache sudo timestamp ahead of the tunnel
task fleet:tunnel # leave this terminal open
# 5. Back in the main terminal: verify the full stack
task fleet:verify
# 6. Open the URL printed by fleet:url and create the initial admin user
task fleet:urlTo use Apple Virtualization instead of Docker (skip the task fleet:tunnel
step above — vfkit's minikube IP is host-routable directly):
CLUSTER_DRIVER=vfkit task cluster:createtask cluster:create invokes minikube start --profile fleet, which
automatically writes the fleet context into ~/.kube/config and marks
it as current. There is nothing to copy or merge by hand — open a fresh
shell and kubectl already points at the new cluster.
Verify:
kubectl config current-context # → fleet
kubectl cluster-info # → control plane at https://127.0.0.1:<port>
kubectl get nodes # → fleet Ready control-planeIf you also have ~/.kube/config entries from other clusters, swap with:
kubectl config get-contexts # list everything kubectl knows about
kubectl config use-context fleet # switch to this project's cluster
kubectl config use-context <other> # switch backA nicer UX:
brew install kubectx # also installs `kubens`
kubectx fleet # switch context
kubens fleet # default namespace for subsequent commandsThe same idea via minikube:
minikube -p fleet kubectl -- get pods -A # explicit, ignores ~/.kube/config
minikube profile list # see every minikube profile on the box
minikube profile fleet # set the default profile for `minikube` commandsAfter task cluster:stop (or a host reboot) the API server is gone and
kubectl calls hang or refuse the connection. Start the cluster first —
the kubeconfig entry survives stop/start cycles:
task cluster:start # = minikube start -p fleet
kubectl get nodes # works againIf ~/.kube/config drifts (e.g. after deleting the cluster manually),
re-derive the entry from the live minikube profile:
minikube -p fleet update-context # rewrites server URL + cert refs
kubectl config use-context fleettask cluster:delete removes the cluster and the kubeconfig entry, so
you don't end up with orphan stale contexts.
$ task --list-all
cluster:create Create local minikube cluster (CLUSTER_DRIVER=docker|vfkit)
cluster:start Start a previously created minikube cluster
cluster:stop Stop the running minikube cluster
cluster:delete Delete local minikube cluster
chart:install Install or upgrade a Helm chart # ad-hoc
chart:lint Lint a Helm chart # ad-hoc
chart:uninstall Uninstall a Helm release # ad-hoc
fleet:install helmfile sync — deploy the full FleetDM stack
fleet:uninstall helmfile destroy + drop namespaces
fleet:diff Preview what `helmfile sync` would change
fleet:lint Lint the helmfile and all referenced values
fleet:template Render manifests without applying
fleet:expose Publish Fleet via HTTPS ingress on a nip.io hostname
fleet:forward Quick UI access via kubectl port-forward (localhost:8080)
fleet:tunnel Bridge cluster IP to host (macOS/docker driver, sudo)
fleet:verify Run end-to-end health checks (pods/MySQL/Valkey/Fleet/ingress)
fleet:status Show pods, services, PVCs across namespaces + helmfile list
fleet:url Print the Fleet server URLs (ingress / port-forward)
fleet:logs Tail Fleet server logs
fleet:prepare-db Re-run init-db + prepare-db Helm hook Jobs
fleet:trust-ca Opt-in: import the local root CA into the host trust store (sudo)
fleet:untrust-ca Remove the local root CA from the host trust store
A provider-agnostic Helm wrapper used by the Fleet tasks and reusable for anything else:
task chart:install \
CHART_REF=oci://registry-1.docker.io/bitnamicharts/redis \
CHART_RELEASE=my-redis \
CHART_NAMESPACE=cache \
CHART_VERSION=20.6.2 \
CHART_VALUES=path/to/values.yaml| Var | Required | Notes |
|---|---|---|
CHART_REF |
yes | Chart reference (repo, OCI URL, tgz, dir) |
CHART_RELEASE |
yes | Helm release name |
CHART_NAMESPACE |
no | Default default; --create-namespace |
CHART_VERSION |
no | Pin chart version |
CHART_VALUES |
no | Path to a values file |
All knobs are env vars consumed by Task — no flags needed.
| Var | Default | Description |
|---|---|---|
CLUSTER_NAME |
fleet |
Minikube profile name |
CLUSTER_DRIVER |
docker |
docker or vfkit |
CLUSTER_CPUS |
4 |
vCPUs |
CLUSTER_MEMORY |
6144 |
RAM (MiB) |
CLUSTER_DISK |
30g |
Disk size |
CLUSTER_K8S_VERSION |
stable |
Kubernetes version |
The version pins below live in helmfile.yaml.gotmpl under
environments.default.values. They are not env-var overridable today —
edit the helmfile if you need to bump them.
| Setting | Value | Source |
|---|---|---|
fleetChartVersion |
v6.9.4 |
fleetdm/fleet |
valkeyChartVersion |
0.9.4 |
valkey/valkey |
ingressNginxChartVersion |
4.13.2 |
ingress-nginx/ingress-nginx |
certManagerChartVersion |
v1.16.2 |
jetstack/cert-manager |
Task-level env vars (consumed only by tasks/*.yaml, not by the helmfile):
| Var | Default | Description |
|---|---|---|
FLEET_NAMESPACE |
fleet |
Namespace for the Fleet server |
DATABASE_NAMESPACE |
database |
Namespace for MySQL |
CACHE_NAMESPACE |
cache |
Namespace for Valkey |
INGRESS_NAMESPACE |
ingress-nginx |
Namespace for the ingress controller |
INGRESS_DNS_SUFFIX |
nip.io |
Wildcard-DNS suffix for fleet:expose |
INGRESS_HOST |
(unset) | Explicit ingress hostname; overrides the auto-derived nip.io host |
CI |
(unset) | When true, fleet:trust-ca is a no-op (no sudo prompts on CI) |
Examples:
CLUSTER_DRIVER=vfkit CLUSTER_MEMORY=8192 task cluster:create
task fleet:install
INGRESS_DNS_SUFFIX=sslip.io task fleet:exposehost=$(kubectl -n fleet get ingress fleet -o jsonpath='{.spec.rules[0].host}')
secret=$(fleetctl get enroll_secret --json | jq -r '.spec.secrets[0].secret')
fleetctl package --type=deb \
--fleet-url="https://$host" \
--enroll-secret="$secret" \
--insecure # only for the local self-signed root CA; drop in productionThe same $host works from any machine that can resolve public DNS.
--insecure is needed only because the dev stack signs the leaf cert
with a self-signed root; trust the root (task fleet:trust-ca on the
host running fleetctl) and --insecure can be dropped.
init-db (creates the fleet schema + user) and prepare-db (runs
fleet prepare db --no-prompt) are both Helm post-install hooks of the
charts/fleet-db-init chart, with a TCP-wait
initContainer that polls MySQL until it's accepting connections. To
re-run both on demand:
task fleet:prepare-db # = helmfile -l name=fleet-db-init syncDrop a chart under charts/<name>/. On the next push to main,
semantic-release cuts a tag and chart-releaser-action packages and
publishes it to gh-pages as a Helm repository:
helm repo add hw https://oleg-tkachuk.github.io/flamingo-hw
helm repo update
helm search repo hwThe stack runs HTTPS-only because Fleet's UI bundle stores its auth
token in a __Host-token cookie marked Secure. Browsers silently drop
that cookie on plain HTTP — login succeeds, but every follow-up API
call goes out as Authorization: Bearer null and the UI bounces back
to /login with Authentication required. There is no server-side or
chart-level switch to disable that behaviour.
Chain produced by charts/cert-manager-issuers/:
ClusterIssuer/fleet-selfsigned (SelfSigned, bootstrap only)
│
└─► Certificate/fleet-root-ca (isCA=true, 10y, in cert-manager ns)
│
└─► ClusterIssuer/fleet-ca (CA, signs all leaf certs)
│
├─► Ingress fleet (annotation: cert-manager.io/cluster-issuer: fleet-ca)
│ → Secret fleet-tls (auto-managed)
│
└─► ingress-nginx admission webhook serving cert
→ Secret ingress-nginx-admission (auto-managed)
→ caBundle injected by cainjector
The Fleet ingress and the ingress-nginx admission webhook are both
auto-rotated by cert-manager. The local root CA stays cluster-resident
until you explicitly task fleet:untrust-ca (and delete the secret).
The browser will warn on first visit (NET::ERR_CERT_AUTHORITY_INVALID
or equivalent) because the root CA is not in your trust store. Just
click through to accept it — the warning sticks per-host and you won't
see it again on that browser profile.
If you want a green lock (e.g. when demoing), trust the root on the host once:
task fleet:trust-ca # macOS: System keychain; Linux: /usr/local/share/ca-certificates
task fleet:untrust-ca # symmetric cleanupfleet:trust-ca is opt-in — it prompts for sudo to write to the
system keychain, so it deliberately stays out of fleet:install. The
task is idempotent (no-op if the cert is already trusted) and short-
circuits on CI=true so it never fires from CI by accident.
task fleet:uninstall # removes both releases + namespace + PVCs
task cluster:delete # destroys the minikube VMyamllintover the whole repo.chart-testing(ct lint) on changed charts undercharts/.helm dependency update+helm templatesmoke test for each changed chart.
semantic-releaseanalyses Conventional Commits, produces a GitHub release and avX.Y.Ztag.- If a release was cut,
chart-releaser-actionrunshelm packagefor every chart and pushes the index + tarballs togh-pages, exposing the repo as a public Helm repository.
Branch protection should require both yamllint and
Helm chart lint & template checks to pass before merge.
brew install lefthook
lefthook installRuns on every git commit:
yamllinton staged YAML fileshelm linton charts whoseChart.yamlis stagedhelm templateon charts whose templates are staged- Conventional Commits enforcement on the commit message
| Symptom | Likely cause / fix |
|---|---|
| Login returns 200 then UI immediately shows Authentication required | Accessing the UI over plain HTTP — Fleet's __Host-token cookie needs HTTPS. Re-run task fleet:expose. |
| Browser warns Your connection is not private | Run task fleet:trust-ca to import the local root CA into the host keychain; restart the browser. |
task fleet:verify reports SKIP: cannot reach ingress ... port 443 |
Run task fleet:tunnel in a second terminal (macOS + docker driver only; minikube's LoadBalancer needs the tunnel to bridge cluster IPs onto 127.0.0.1). |
curl: (7) Failed to connect to fleet.*.nip.io |
Same as above — the LoadBalancer Service has no host-routable IP. Start task fleet:tunnel. |
kubectl apply on an Ingress fails with x509: signed by unknown authority |
Stale ingress-nginx admission webhook with empty caBundle. Re-run helmfile -l name=ingress-nginx sync. |
Prepare-db Job stays Pending |
MySQL not yet Ready — Job has a TCP-wait initContainer; check kubectl -n fleet describe job/fleet-prepare-db. |
nip.io hostname doesn't resolve |
Air-gapped network — fall back to task fleet:forward or NodePort. |
helm dep update 401 on OCI |
helm registry login to docker.io may be required for OCI rate-limits. |
Inspect anything with:
task fleet:status # pods, svc, pvc, ingress
task fleet:logs # follow Fleet server logs
kubectl -n fleet get events --sort-by=.lastTimestamp| Component | Version | Source |
|---|---|---|
| FleetDM server | v4.84.3 |
docker.io/fleetdm/fleet |
| FleetDM chart | v6.9.4 |
https://fleetdm.github.io/fleet/charts (fleetdm/fleet) |
| MySQL | 8.4 |
docker.io/library/mysql via charts/mysql/ |
| Valkey chart | 0.9.4 |
https://valkey.io/valkey-helm/ (valkey/valkey) |
| ingress-nginx chart | 4.13.2 |
https://kubernetes.github.io/ingress-nginx (ingress-nginx/ingress-nginx) |
| cert-manager chart | v1.16.2 |
https://charts.jetstack.io (jetstack/cert-manager) |
To bump anything, edit the pins under environments.default.values in
helmfile.yaml.gotmpl.
docs/ARCHITECTURE.md— AWS Well-Architected solution design (cloud accounts, VPC, EKS, CI/CD, MongoDB Atlas)- Fleet deploy guide
deploy/values/README.md— release-graph + values walkthroughcharts/mysql/README.md— why a standalone MySQL chartcharts/fleet-db-init/README.md— init / prepare-db Jobscharts/cert-manager-issuers/README.md— issuer chain- cert-manager docs
- Task documentation
- Helm chart-releaser action
- nip.io — wildcard DNS for
<ip>.nip.io
See repository LICENSE (TBD).