This guide provides e for provisioning, configuring, and verifying a local single-node Kubernetes cluster tailored for data science workloads.
The process uses Ansible to provision k3s, Cilium eBPF networking, Flux GitOps controllers, in-cluster HashiCorp Vault secrets management, and OpenTofu infrastructure configurations. Once Ansible finishes, Flux takes over GitOps management of the cluster.
Once host-side prerequisites are met, running just bootstrap orchestrates the entire cluster lifecycle and reconciles all platform services declaratively.
curl -s https://fluxcd.io/install.sh | sudo bash
flux check --pre-
Ubuntu / Debian:
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /usr/share/keyrings/githubcli-archive-keyring.gpg > /dev/null echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null sudo apt update && sudo apt install -y gh
-
Fedora / RHEL / Red Hat:
sudo dnf install -y 'dnf-command(config-manager)' 2>/dev/null || sudo dnf install -y dnf-plugins-core sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo sudo dnf install -y gh
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod +x get_helm.sh
./get_helm.sh
rm get_helm.sh
helm version-
Ubuntu / Debian:
sudo apt update && sudo apt install -y just -
Fedora / RHEL / Red Hat:
sudo dnf install -y just
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
chmod +x install-opentofu.sh
sudo ./install-opentofu.sh --install-method standalone
rm install-opentofu.sh
tofu version-
Ubuntu / Debian:
sudo apt update && sudo apt install -y postgresql-client-common postgresql-client -
Fedora / RHEL / Red Hat:
sudo dnf install -y postgresql
The cluster requires read-only access to this repository to synchronize manifests. You can authenticate using either a GitHub App (Recommended) or your personal GitHub CLI token.
Option 1: GitHub App (Recommended)
For secure, automated rotation of short-lived tokens, create a GitHub App in your account with Read-only access to Contents.
-
Generate a Private Key (
.pem) for the app and save it locally. -
Install the App on your repository.
-
Export the following environment variables before running the bootstrap:
export GITHUB_APP_ID="<your-app-id>" export GITHUB_APP_INSTALLATION_ID="<your-installation-id>" export GITHUB_APP_PRIVATE_KEY_PATH="/path/to/your/private-key.pem"
Option 2: GitHub CLI (Fallback) If you do not have a GitHub App configured, the bootstrap process will fall back to using your personal GitHub token.
-
Authenticate the CLI:
gh auth login
-
Verify your status:
gh auth status
-
Host firewall configured for Cilium — you can configure your host firewall automatically using the
just setup-firewallrecipe, or apply the manual commands below for your distribution:just setup-firewall
-
Ubuntu / Debian (
ufw): Ifufwis active, allow Cilium's network interfaces and set default forward policy toACCEPT:sudo ufw allow in on cilium_host sudo ufw allow in on cilium_net sudo ufw allow in on cilium_vxlan sudo ufw allow in on lxc+ sudo sed -i 's/DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw sudo ufw reload
Verify rule status:
sudo ufw status verbose # Ensure DEFAULT_FORWARD_POLICY is accept (routed) # Ensure cilium_host, cilium_net, cilium_vxlan, and lxc+ are ALLOW IN
-
Fedora / RHEL (
firewalld):ufwis not present on Fedora and RHEL;firewalldserves the same role. Allow forwarding, masquerading, DNS, and add Cilium interfaces/subnets totrusted:# 1. Base zone configuration (DNS, Gateway port, forward) # Masquerade is Cilium's job, not firewalld's. sudo firewall-cmd --zone=FedoraWorkstation --add-service=dns --permanent sudo firewall-cmd --zone=FedoraWorkstation --add-port=443/tcp --permanent sudo firewall-cmd --zone=FedoraWorkstation --add-forward --permanent sudo firewall-cmd --zone=trusted --add-service=dns --permanent sudo firewall-cmd --zone=trusted --add-source=10.42.0.0/16 --permanent sudo firewall-cmd --zone=trusted --add-source=10.43.0.0/16 --permanent sudo firewall-cmd --zone=trusted --add-interface=cilium_host --permanent sudo firewall-cmd --zone=trusted --add-interface=cilium_net --permanent sudo firewall-cmd --zone=trusted --add-interface=cilium_vxlan --permanent # 2. Container forwarding policies (priority -1: ANY <-> trusted, HOST -> ANY) sudo firewall-cmd --permanent --new-policy=k8s-forwarding-in 2>/dev/null || true sudo firewall-cmd --permanent --policy=k8s-forwarding-in --set-priority=-1 sudo firewall-cmd --permanent --policy=k8s-forwarding-in --add-ingress-zone=ANY sudo firewall-cmd --permanent --policy=k8s-forwarding-in --add-egress-zone=trusted sudo firewall-cmd --permanent --policy=k8s-forwarding-in --set-target=ACCEPT sudo firewall-cmd --permanent --new-policy=k8s-forwarding-out 2>/dev/null || true sudo firewall-cmd --permanent --policy=k8s-forwarding-out --set-priority=-1 sudo firewall-cmd --permanent --policy=k8s-forwarding-out --add-ingress-zone=trusted sudo firewall-cmd --permanent --policy=k8s-forwarding-out --add-egress-zone=ANY sudo firewall-cmd --permanent --policy=k8s-forwarding-out --set-target=ACCEPT sudo firewall-cmd --permanent --new-policy=k8s-forwarding-host 2>/dev/null || true sudo firewall-cmd --permanent --policy=k8s-forwarding-host --set-priority=-1 sudo firewall-cmd --permanent --policy=k8s-forwarding-host --add-ingress-zone=HOST sudo firewall-cmd --permanent --policy=k8s-forwarding-host --add-egress-zone=ANY sudo firewall-cmd --permanent --policy=k8s-forwarding-host --set-target=ACCEPT sudo firewall-cmd --reload
Verify rule and policy status:
sudo firewall-cmd --list-all sudo firewall-cmd --zone=trusted --list-all sudo firewall-cmd --get-policies
-
Reserved IP range excluded from DHCP — the cluster claims
192.0.2.240–192.0.2.250on your LAN by default (editterraform/cluster-config/terraform.tfvarsto change this). Confirm your router's DHCP pool doesn't hand these out, and that nothing already answers on them:ping -c 2 -W 1 192.0.2.240 ping -c 2 -W 1 192.0.2.242
If reinstalling over an existing k3s instance, tear it down first:
just uninstallHave your .dump file prepared to restore once the CNPG cluster exists (see Verify Database Deployment):
kubectl exec -i postgis-cluster-1 -n databases -- pg_restore -U postgres -d data_science --no-owner --no-privileges \
< /mnt/your/mount/path/data_science_backup_*.dumpThe --no-owner --no-privileges flags ensure restored objects inherit ownership under app_readwrite.
git clone https://github.com/DragonBishop/data_science_cluster.git
cd data_science_clusterRuns the full setup via Ansible (ansible/playbooks/data_cluster.yml): k3s, Cilium, Flux, Vault, and terraform/vault. Idempotent and accepts optional flags (e.g. --tags, --check, -v). When prompted for BECOME password:, enter your local user's sudo password to allow root-level setup of /etc/rancher/k3s/ and systemd services.
Tip
Just Recipe (automatically prompts for sudo):
just bootstrapNote
Manual Shell Command:
ansible-playbook -i ansible/inventory/hosts.ini ansible/playbooks/data_cluster.yml --ask-become-passThe bootstrap script prompts for a GPG passphrase and an OpenTofu state-encryption passphrase, then prints the in-cluster Vault unseal keys and root token.
Important
Store the generated unseal keys and root token in a secure password manager immediately. Data cannot be recovered if these keys are lost.
Future cluster starts (just start / start-cluster.sh) unseal Vault automatically using the GPG-encrypted keyfile (~/.vault-keys.gpg) written during bootstrap. Note that the cache-TTL setting in ~/.gnupg/gpg-agent.conf only governs gpg-agent's in-memory cache; on desktop environments with a keyring-integrated pinentry (e.g. pinentry-gnome3), the passphrase can also be stored in the OS keyring. Add no-allow-external-cache to ~/.gnupg/gpg-agent.conf to disable OS keyring caching.
flowchart TD
%% Base Foundations
crds["gateway-api-crds"] --> cilium["cilium"]
ns["namespaces"] --> cilium
cc["cluster-config"] --> cilium
%% Core Services & PKI
cilium --> coredns["coredns-custom"]
cilium --> fluxpolicies["flux-system-policies"]
cilium --> certmgr["cert-manager"]
certmgr --> vault["vault"]
%% Platform Services
vault --> vso["vault-secrets-operator"]
vso --> cnpg["cnpg-operator"]
cnpg --> barman["barman-cloud"]
vault --> gw["gateway"]
gw --> hubble["hubble"]
%% Applications
barman --> db["databases"]
vault --> db
gw --> db
ciliumrequiresgateway-api-crdsandnamespaces.coredns-custom,flux-system-policies, andcert-managerdepend oncilium.vaultdepends oncert-manager(forvault-server-certTLS bootstrap).vault-secrets-operatorandgatewaydepend onvault(for PKI and secrets sync).cnpg-operatordepends onvault-secrets-operator, andbarman-clouddepends oncnpg-operator.hubbledepends ongateway(attaching thehubble.internalHTTPRoute).databasesdepends onbarman-cloud,gateway, andvault.
just bootstrap deploys the cluster. The checks below verify what Flux already reconciled.
Tip
Just Recipe (bundled into the full health check):
just statusNote
Manual Shell Command:
kubectl cnpg status postgis-cluster -n databasesVerify output shows Status: Healthy, 1/1 ready, and WAL archiving: OK.
Tip
Just Recipe (bundled into the full health check):
just statusNote
Manual Shell Command:
kubectl get database -n databasesVerify output shows postgis-cluster/data-science with status.applied: true.
kubectl exec -i postgis-cluster-1 -n databases -- psql -U postgres -d data_science -c '\dx'Verify installed extensions: postgis, postgis_topology, postgis_tiger_geocoder, and fuzzystrmatch.
kubectl exec -i postgis-cluster-1 -n databases -- psql -U postgres -d postgres -c \
"SELECT datname, pg_catalog.pg_get_userbyid(datdba) FROM pg_database WHERE datname='data_science';"Verify database owner is app_readwrite.
Tip
Just Recipe (bundled into the full health check):
just statusNote
Manual Shell Command:
kubectl get tcproute -n databases postgis-external -o jsonpath='{.status.parents[*].conditions[*].message}'Verify condition returns Service reference is valid.
kubectl exec -i postgis-cluster-1 -n databases -- psql -U postgres -d data_science -c '\dn'Verify all application schemas are present.
Test database connectivity from your LAN workstation through the Gateway IP:
Tip
Just Recipe:
just db-connectNote
Manual Shell Command:
LEASE_USER=$(kubectl get secret -n databases postgis-app-dynamic-credentials -o jsonpath='{.data.username}' | base64 -d)
LEASE_PASS=$(kubectl get secret -n databases postgis-app-dynamic-credentials -o jsonpath='{.data.password}' | base64 -d)
mkdir -p ~/.postgresql
[ -f ~/.postgresql/root.crt ] || kubectl get secret postgis-server-cert -n databases -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.postgresql/root.crt
PGPASSWORD="$LEASE_PASS" psql "host=192.0.2.240 port=5432 dbname=data_science user=$LEASE_USER sslmode=verify-full"On the k3s node itself, CiliumLocalRedirectPolicy redirects 127.0.0.1:5432 to the CNPG primary pod:
Tip
Just Recipe:
just db-connect localhostNote
Manual Shell Command:
LEASE_USER=$(kubectl get secret -n databases postgis-app-dynamic-credentials -o jsonpath='{.data.username}' | base64 -d)
LEASE_PASS=$(kubectl get secret -n databases postgis-app-dynamic-credentials -o jsonpath='{.data.password}' | base64 -d)
mkdir -p ~/.postgresql
[ -f ~/.postgresql/root.crt ] || kubectl get secret postgis-server-cert -n databases -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.postgresql/root.crt
PGPASSWORD="$LEASE_PASS" psql "host=localhost port=5432 dbname=data_science user=$LEASE_USER sslmode=verify-full"Once the CNPG cluster is healthy and VSO reconciles apps/databases/vso-setup.yaml, VSO requests credentials from Vault and writes them to the postgis-app-dynamic-credentials Secret.
kubectl get vaultdynamicsecret postgis-app-dynamic-secret -n databases
kubectl exec -i postgis-cluster-1 -n databases -- psql -U postgres -d postgres -c '\du'Verify the generated role is a member of app_readwrite.
Tip
Just Recipe:
just db-connectNote
Manual Shell Command:
LEASE_USER=$(kubectl get secret -n databases postgis-app-dynamic-credentials -o jsonpath='{.data.username}' | base64 -d)
LEASE_PASS=$(kubectl get secret -n databases postgis-app-dynamic-credentials -o jsonpath='{.data.password}' | base64 -d)
mkdir -p ~/.postgresql
[ -f ~/.postgresql/root.crt ] || kubectl get secret postgis-server-cert -n databases -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.postgresql/root.crt
PGPASSWORD="$LEASE_PASS" psql "host=192.0.2.240 port=5432 dbname=data_science user=$LEASE_USER sslmode=verify-full"Test Gateway listener routing and edge certificate termination:
Tip
Just Recipe:
just gateway-checkNote
Manual Shell Command:
curl -v --resolve hubble.internal:443:192.0.2.240 \
--cacert <(kubectl get secret -n gateway internal-edge-cert -o jsonpath='{.data.ca\.crt}' | base64 -d) \
https://hubble.internal/Verify that the page responds and the certificate chains to vault-pki-issuer's CA (internal-edge-cert).
Verify network visibility and access the Hubble UI / CLI:
- Web UI Access:
just hubble-uiport-forwards tolocalhost:12000and opens the UI in your default browser. - CLI Flow Streaming:
just hubble statusandjust hubble observe --followstream flows from Hubble Relay over mTLS (port 4245).
Check the overall operational status and reconciliation health of all cluster components:
Tip
Just Recipe:
just statusNote
Manual Shell Command:
flux get kustomizationsVerify automated backup schedules and trigger an on-demand backup to SeaweedFS S3:
Tip
Just Recipe (bundled into the full health check):
just statusNote
Manual Shell Command:
kubectl get scheduledbackup -n databasesVerify suspend: false and scheduled backup intervals.
kubectl cnpg backup postgis-cluster -n databasesTip
Just Recipe (bundled into the full health check):
just statusNote
Manual Shell Command:
kubectl cnpg status postgis-cluster -n databasesVerify Last Successful Backup timestamp updates to the current time.