Today we rolled out Bitnami Sealed Secrets to our k3s cluster using Helm, hit a snag with the kubeseal CLI artifact, fixed it by pinning a working release, and verified the full round-trip: SealedSecret → controller decrypts → Secret recreated after deletion. To keep the day lean, we used a minimal number of Ansible playbooks and made them idempotent.
What we built
- Installed the Sealed Secrets controller via Helm in
kube-system. - Installed a working kubeseal binary on the control node.
- Created a demo SealedSecret for
apps/demo-secret. - Verified the controller decrypts and (re)creates the underlying
Secret.
Prerequisites
- k3s control node reachable over SSH (
k3s-masterin inventory). KUBECONFIGon the node:/etc/rancher/k3s/k3s.yaml.- Helm present on the node.
Playbook 1 — day12_sealed_secrets.yml
Installs the controller via Helm, installs a working kubeseal, and seals a demo Secret.
---
- name: Install Sealed Secrets controller (Helm) on cluster
hosts: k3s-master
gather_facts: false
vars:
kubeconfig_path: /etc/rancher/k3s/k3s.yaml
controller_namespace: kube-system
controller_name: sealed-secrets
tasks:
- name: Add Helm repo (idempotent)
shell: |
set -euo pipefail
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets 2>&1 || true
echo "ok"
args:
executable: /bin/bash
changed_when: "'already exists' not in (result.stdout | default(''))"
register: result
- name: Helm repo update
command: helm repo update
changed_when: false
- name: Install/upgrade Sealed Secrets controller
command: >
helm upgrade --install {{ controller_name }} sealed-secrets/sealed-secrets
-n {{ controller_namespace }} --create-namespace
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Wait for controller rollout
command: >
kubectl -n {{ controller_namespace }} rollout status deploy/{{ controller_name }} --timeout=180s
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Install kubeseal and create a demo SealedSecret
hosts: k3s-master
gather_facts: false
vars:
kubeconfig_path: /etc/rancher/k3s/k3s.yaml
# Working version (tar layout includes a "kubeseal" binary)
kubeseal_version: v0.24.5
kubeseal_url: "https://github.com/bitnami-labs/sealed-secrets/releases/download/{{ kubeseal_version }}/kubeseal-{{ kubeseal_version }}-linux-amd64.tar.gz"
kubeseal_tar: /tmp/kubeseal.tar.gz
kubeseal_bin: /usr/local/bin/kubeseal
controller_namespace: kube-system
controller_name: sealed-secrets
controller_cert: /tmp/sealed-secrets.pem
demo_ns: apps
demo_secret_name: demo-secret
demo_secret_manifest: "/tmp/{{ demo_secret_name }}.yaml"
demo_sealed_manifest: "/tmp/{{ demo_secret_name }}-sealed.yaml"
demo_username: demo
demo_password: "S3cretP@ssw0rd!"
tasks:
- name: Check current kubeseal works
command: "{{ kubeseal_bin }} --version"
register: kubeseal_ver
failed_when: false
changed_when: false
- name: Remove broken kubeseal (if needed)
file:
path: "{{ kubeseal_bin }}"
state: absent
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Download kubeseal tarball (fresh)
get_url:
url: "{{ kubeseal_url }}"
dest: "{{ kubeseal_tar }}"
mode: '0644'
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Extract kubeseal
unarchive:
src: "{{ kubeseal_tar }}"
dest: /tmp
remote_src: true
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Install kubeseal to /usr/local/bin
copy:
src: /tmp/kubeseal
dest: "{{ kubeseal_bin }}"
mode: '0755'
remote_src: true
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Verify kubeseal installed
command: "{{ kubeseal_bin }} --version"
changed_when: false
- name: Fetch controller public cert (PEM)
shell: |
set -euo pipefail
{{ kubeseal_bin }} \
--controller-name {{ controller_name }} \
--controller-namespace {{ controller_namespace }} \
--fetch-cert > {{ controller_cert }}
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Ensure namespace exists for demo
command: "kubectl get ns {{ demo_ns }}"
register: ns_check
failed_when: false
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Create namespace if missing
command: "kubectl create ns {{ demo_ns }}"
when: ns_check.rc != 0
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Render demo Secret manifest (unencrypted, local temp)
copy:
dest: "{{ demo_secret_manifest }}"
mode: '0600'
content: |
apiVersion: v1
kind: Secret
metadata:
name: {{ demo_secret_name }}
namespace: {{ demo_ns }}
type: Opaque
stringData:
username: "{{ demo_username }}"
password: "{{ demo_password }}"
- name: Seal the Secret into a SealedSecret
shell: |
set -euo pipefail
cat {{ demo_secret_manifest }} \
| {{ kubeseal_bin }} --format=yaml --cert {{ controller_cert }} \
> {{ demo_sealed_manifest }}
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Apply SealedSecret (idempotent)
command: "kubectl apply -f {{ demo_sealed_manifest }}"
register: apply_sealed
changed_when: "'created' in (apply_sealed.stdout|default('')) or 'configured' in (apply_sealed.stdout|default(''))"
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Wait for decrypted Secret to appear
shell: |
set -euo pipefail
kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o name
args:
executable: /bin/bash
register: have_secret
retries: 30
delay: 3
until: have_secret.rc == 0
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
Playbook 2 — day12_sealed_secrets-fix.yml
Focused “fix” when the first kubeseal attempt failed due to an incompatible tarball. Pins v0.24.5 and repeats the sealing flow.
---
- name: Fix kubeseal and seal demo secret
hosts: k3s-master
gather_facts: false
vars:
kubeconfig_path: /etc/rancher/k3s/k3s.yaml
kubeseal_version: v0.32.2
kubeseal_tar_version: 0.32.2
kubeseal_url: "https://github.com/bitnami-labs/sealed-secrets/releases/download/{{ kubeseal_version }}/kubeseal-{{ kubeseal_tar_version }}-linux-amd64.tar.gz"
kubeseal_tar: /tmp/kubeseal.tar.gz
kubeseal_bin: /usr/local/bin/kubeseal
controller_namespace: kube-system
controller_name: sealed-secrets
controller_cert: /tmp/sealed-secrets.pem
demo_ns: apps
demo_secret_name: demo-secret
demo_secret_manifest: "/tmp/{{ demo_secret_name }}.yaml"
demo_sealed_manifest: "/tmp/{{ demo_secret_name }}-sealed.yaml"
demo_username: demo
demo_password: "S3cretP@ssw0rd!"
tasks:
- name: Check current kubeseal works
command: "{{ kubeseal_bin }} --version"
register: kubeseal_ver
failed_when: false
changed_when: false
- name: Remove broken kubeseal (if needed)
file:
path: "{{ kubeseal_bin }}"
state: absent
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Download kubeseal tarball (fresh)
get_url:
url: "{{ kubeseal_url }}"
dest: "{{ kubeseal_tar }}"
mode: '0644'
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Extract kubeseal
unarchive:
src: "{{ kubeseal_tar }}"
dest: /tmp
remote_src: true
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Install kubeseal to /usr/local/bin
copy:
src: /tmp/kubeseal
dest: "{{ kubeseal_bin }}"
mode: '0755'
remote_src: true
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Verify kubeseal installed
command: "{{ kubeseal_bin }} --version"
changed_when: false
- name: Fetch controller public cert (PEM)
shell: |
set -euo pipefail
{{ kubeseal_bin }} \
--controller-name {{ controller_name }} \
--controller-namespace {{ controller_namespace }} \
--fetch-cert > {{ controller_cert }}
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Ensure namespace exists for demo
command: "kubectl get ns {{ demo_ns }}"
register: ns_check
failed_when: false
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Create namespace if missing
command: "kubectl create ns {{ demo_ns }}"
when: ns_check.rc != 0
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Render demo Secret manifest (unencrypted, local temp)
copy:
dest: "{{ demo_secret_manifest }}"
mode: '0600'
content: |
apiVersion: v1
kind: Secret
metadata:
name: {{ demo_secret_name }}
namespace: {{ demo_ns }}
type: Opaque
stringData:
username: "{{ demo_username }}"
password: "{{ demo_password }}"
- name: Seal the Secret into a SealedSecret
shell: |
set -euo pipefail
cat {{ demo_secret_manifest }} \
| {{ kubeseal_bin }} --format=yaml --cert {{ controller_cert }} \
> {{ demo_sealed_manifest }}
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Apply SealedSecret (idempotent)
command: "kubectl apply -f {{ demo_sealed_manifest }}"
register: apply_sealed
changed_when: "'created' in (apply_sealed.stdout|default('')) or 'configured' in (apply_sealed.stdout|default(''))"
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Wait for decrypted Secret to appear
shell: |
set -euo pipefail
kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o name
args:
executable: /bin/bash
register: have_secret
retries: 30
delay: 3
until: have_secret.rc == 0
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
# OPRAVA kubeseal + pokračovanie
- name: Fix kubeseal and seal demo secret
hosts: k3s-master
gather_facts: false
vars:
kubeconfig_path: /etc/rancher/k3s/k3s.yaml
kubeseal_version: v0.24.5
kubeseal_url: "https://github.com/bitnami-labs/sealed-secrets/releases/download/{{ kubeseal_version }}/kubeseal-{{ kubeseal_version }}-linux-amd64.tar.gz"
kubeseal_tar: /tmp/kubeseal.tar.gz
kubeseal_bin: /usr/local/bin/kubeseal
controller_namespace: kube-system
controller_name: sealed-secrets
controller_cert: /tmp/sealed-secrets.pem
demo_ns: apps
demo_secret_name: demo-secret
demo_secret_manifest: "/tmp/{{ demo_secret_name }}.yaml"
demo_sealed_manifest: "/tmp/{{ demo_secret_name }}-sealed.yaml"
demo_username: demo
demo_password: "S3cretP@ssw0rd!"
tasks:
- name: Check current kubeseal works
command: "{{ kubeseal_bin }} --version"
register: kubeseal_ver
failed_when: false
changed_when: false
- name: Remove broken kubeseal (if needed)
file:
path: "{{ kubeseal_bin }}"
state: absent
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Download kubeseal tarball (fresh)
get_url:
url: "{{ kubeseal_url }}"
dest: "{{ kubeseal_tar }}"
mode: '0644'
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Extract kubeseal
unarchive:
src: "{{ kubeseal_tar }}"
dest: /tmp
remote_src: true
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Install kubeseal to /usr/local/bin
copy:
src: /tmp/kubeseal
dest: "{{ kubeseal_bin }}"
mode: '0755'
remote_src: true
when: kubeseal_ver.rc != 0 or ('kubeseal' not in (kubeseal_ver.stdout|default('')))
- name: Verify kubeseal installed
command: "{{ kubeseal_bin }} --version"
changed_when: false
- name: Fetch controller public cert (PEM)
shell: |
set -euo pipefail
{{ kubeseal_bin }} \
--controller-name {{ controller_name }} \
--controller-namespace {{ controller_namespace }} \
--fetch-cert > {{ controller_cert }}
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Ensure namespace exists for demo
command: "kubectl get ns {{ demo_ns }}"
register: ns_check
failed_when: false
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Create namespace if missing
command: "kubectl create ns {{ demo_ns }}"
when: ns_check.rc != 0
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Render demo Secret manifest (unencrypted, local temp)
copy:
dest: "{{ demo_secret_manifest }}"
mode: '0600'
content: |
apiVersion: v1
kind: Secret
metadata:
name: {{ demo_secret_name }}
namespace: {{ demo_ns }}
type: Opaque
stringData:
username: "{{ demo_username }}"
password: "{{ demo_password }}"
- name: Seal the Secret into a SealedSecret
shell: |
set -euo pipefail
cat {{ demo_secret_manifest }} \
| {{ kubeseal_bin }} --format=yaml --cert {{ controller_cert }} \
> {{ demo_sealed_manifest }}
args:
executable: /bin/bash
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Apply SealedSecret (idempotent)
command: "kubectl apply -f {{ demo_sealed_manifest }}"
register: apply_sealed
changed_when: "'created' in (apply_sealed.stdout|default('')) or 'configured' in (apply_sealed.stdout|default(''))"
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
- name: Wait for decrypted Secret to appear
shell: |
set -euo pipefail
kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o name
args:
executable: /bin/bash
register: have_secret
retries: 30
delay: 3
until: have_secret.rc == 0
changed_when: false
environment:
KUBECONFIG: "{{ kubeconfig_path }}"
Ako otestujem teraz spravnost instalacie seal secret ?
Verification — end-to-end test
We validated three things:
- Controller & CRD are ready:
kubectl -n kube-system rollout status deploy/sealed-secrets --timeout=180s
kubectl get crd sealedsecrets.bitnami.com
kubeseal --version
- The decrypted Secret exists and is managed by the controller:
kubectl -n apps get secret demo-secret -o name
kubectl -n apps get secret demo-secret \
-o jsonpath='{.metadata.annotations.sealedsecrets\.bitnami\.com/managed}'; echo
# expected: true
- Recreation after deletion (controller re-materializes the Secret):
kubectl -n apps get secret demo-secret -o jsonpath='{.metadata.resourceVersion}'
kubectl -n apps delete secret demo-secret
# wait & confirm it reappears
for i in {1..20}; do
kubectl -n apps get secret demo-secret >/dev/null 2>&1 && echo "OK (recreated)" && break
sleep 3
done
(Optional) Compact verification as an Ansible playbook:
---
- name: Day 12 | Verify Sealed Secrets installation & round-trip
hosts: k3s-master
gather_facts: false
vars:
kubeconfig_path: /etc/rancher/k3s/k3s.yaml
controller_namespace: kube-system
controller_name: sealed-secrets
demo_ns: apps
demo_secret_name: demo-secret
tasks:
- name: Check CRD exists
command: kubectl get crd sealedsecrets.bitnami.com
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Wait for controller rollout
command: kubectl -n {{ controller_namespace }} rollout status deploy/{{ controller_name }} --timeout=120s
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Check kubeseal works
command: kubeseal --version
register: kubeseal_ver
changed_when: false
failed_when: kubeseal_ver.rc != 0
- name: Verify SealedSecret exists
command: kubectl -n {{ demo_ns }} get sealedsecret {{ demo_secret_name }} -o name
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Verify decrypted Secret exists
command: kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o name
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Check managed annotation on Secret
command: >
kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }}
-o jsonpath={{'{'}}.metadata.annotations.sealedsecrets\.bitnami\.com/managed{{'}'}}
register: managed_anno
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Assert managed annotation present and true
assert:
that:
- managed_anno.stdout | lower == "true"
fail_msg: "Secret is not marked as managed by sealed-secrets controller."
success_msg: "Secret is managed by sealed-secrets controller."
- name: Capture current Secret resourceVersion
command: kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o jsonpath='{.metadata.resourceVersion}'
register: secret_rv_before
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Delete Secret (controller should recreate it)
command: kubectl -n {{ demo_ns }} delete secret {{ demo_secret_name }}
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Wait for Secret to reappear
command: kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o name
register: have_secret
retries: 20
delay: 3
until: have_secret.rc == 0
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Compare resourceVersion (should differ after recreation)
command: kubectl -n {{ demo_ns }} get secret {{ demo_secret_name }} -o jsonpath='{.metadata.resourceVersion}'
register: secret_rv_after
changed_when: false
environment: { KUBECONFIG: "{{ kubeconfig_path }}" }
- name: Assert resourceVersion changed
assert:
that:
- secret_rv_before.stdout != secret_rv_after.stdout
fail_msg: "Secret did not get recreated/updated after deletion."
success_msg: "Secret was recreated by the controller (resourceVersion changed)."
Key takeaways
- Pinning a known-good kubeseal release avoids tar/packaging mismatches.
- The controller public certificate is required to seal manifests offline.
- The
name + namespacepair is part of the encryption scope—changing either breaks decryption. - The controller will recreate Secrets from SealedSecrets—great for GitOps workflows.