Flux was installed and watching the repository. I wanted to test it with an application, so I chose Linkding, a self-hosted bookmark manager, and gave it its own namespace.
The rule for this exercise was simple: define the desired state, push it to Git and let Flux perform the deployment. No kubectl apply.
Following the source
Flux's bootstrap process had already committed gotk-sync.yaml under clusters/staging/flux-system. Among other resources, that file defines a Flux GitRepository pointing at my zbook-cluster repository.
GitRepository is not a built-in Kubernetes resource. Flux installs the Custom Resource Definition and runs a controller that knows how to reconcile it. I could inspect the resulting object with:
kubectl -n flux-system get gitrepositories.source.toolkit.fluxcd.io
NAME URL READY STATUS
flux-system ssh://git@github.com/joshzee/zbook-cluster True stored artifact for revision 'main@sha1:44b008d2...'
That status meant the Source Controller had fetched the repository and stored an artifact for the displayed commit. I could now add the application manifests under the path Flux was watching.
Giving the repository a shape
I followed Flux's monorepo structure guidance, separating reusable application definitions from environment-specific configuration:
.
├── apps
│ ├── base
│ │ └── linkding
│ │ ├── deployment.yaml
│ │ ├── kustomization.yaml
│ │ └── namespace.yaml
│ └── staging
│ └── linkding
│ └── kustomization.yaml
└── clusters
└── staging
├── apps.yaml
└── flux-system
├── gotk-components.yaml
├── gotk-sync.yaml
└── kustomization.yaml
The separation gives each layer a clear responsibility:
clusters/stagingis the entry point already watched by the bootstrap configuration.clusters/staging/apps.yamldefines a FluxKustomizationthat points atapps/staging.apps/staging/linkdingselects the reusable Linkding base and applies it to the staging environment.apps/base/linkdingcontains the ordinary Kubernetes resources shared by any future environment.
The local kubeconfig used by my workstation does not belong in this structure. It remains ignored by Git because it contains cluster-administrator credentials.
Two kinds named Kustomization
This setup exposed an initially confusing naming collision.
The resource in apps.yaml is a Flux Kustomization, from the kustomize.toolkit.fluxcd.io API group. It tells a Flux controller which repository path to build and how often to reconcile it. In my staging configuration, the interval is one minute:
spec:
interval: 1m0s
The files named kustomization.yaml inside the application directories are Kustomize configurations, using kustomize.config.k8s.io/v1beta1. They compose and customise Kubernetes YAML.
The base configuration maps the two resources needed for this first deployment:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- namespace.yaml
namespace.yaml creates the dedicated linkding namespace. deployment.yaml requests one replica of the Linkding container and exposes its application port inside the pod:
spec:
replicas: 1
template:
spec:
containers:
- name: linkding
image: sissbruecker/linkding:1.31.0
ports:
- containerPort: 9090
This is intentionally a minimal first deployment. It does not yet include persistent storage, hardened security configuration or public ingress.
The deployment command was a Git push
With the desired state written, I committed it using a Conventional Commits-style message and pushed it to main:
git add .
git commit -m "feat: deploy linkding"
git push origin main
Using git add . assumes sensitive local files are already ignored. Before publishing a GitOps repository, git ls-files kubeconfig should return no output.
Immediately after the push, Flux was still reporting the previous revision:
NAME REVISION READY MESSAGE
flux-system main@sha1:44b008d2 True Applied revision: main@sha1:44b008d2
That was expected. Reconciliation is asynchronous: the Source Controller first needs to observe and publish the new Git artifact, then the Kustomizations react to that revision. With one-minute intervals configured in this lab, the old revision briefly remained visible. Shortly afterwards, both Kustomizations reported the new commit:
NAME REVISION READY MESSAGE
apps main@sha1:a9e88d65 True Applied revision: main@sha1:a9e88d65
flux-system main@sha1:a9e88d65 True Applied revision: main@sha1:a9e88d65
The chain had completed: the bootstrap Kustomization reconciled clusters/staging, apps.yaml created the application-level reconciliation, and the apps Kustomization built and applied the staging manifests.
Verifying what Git created
Git was the deployment interface, but kubectl remained useful for observation. First, I checked the namespaces:
kubectl get namespaces
NAME STATUS
default Active
flux-system Active
kube-system Active
linkding Active
Then I checked the workload:
kubectl get pods -n linkding
NAME READY STATUS RESTARTS
linkding-678cf4b484-kmtxf 1/1 Running 0
I still used kubectl throughout this process to inspect namespaces and pods. Flux handled their creation from the manifests I'd committed.
To reach the application locally, I forwarded port 8080 on my MacBook to port 9090 in the deployment:
kubectl -n linkding port-forward deployment/linkding 8080:9090
Opening http://localhost:8080 presented the Linkding login page. The first application was alive, and its arrival in the cluster could be traced to a specific Git commit.
At this checkpoint:
- Flux is reconciling both the cluster entry point and the staging applications path.
- Linkding has its own namespace and a running pod.
- The deployment is declared in Git and reproducible from the repository.
kubectl applyplayed no part in the deployment.- Linkding is still temporary, locally accessible and not yet hardened.
Before I start keeping bookmarks in it, Linkding needs persistent storage and tighter permissions. Access beyond the local port-forward will come after that.