Secrets Management with Infisical and External Secrets Operator

Secrets Management with Infisical and External Secrets Operator architecture diagram
Click to expand
1116 × 471px

GitOps has a fundamental tension: everything should be in Git, but secrets shouldn't be in Git. You need database passwords, API keys, and tokens to deploy applications, but committing them to a repository is a security incident waiting to happen.

This post covers how to solve this with Infisical and External Secrets Operator (ESO) - a combination that keeps secrets out of Git while letting Kubernetes applications access them seamlessly.

Series context: This post is part of the Homelab Kubernetes Series. In Part 2 (Bootstrap), I briefly mentioned using Infisical and ESO to fetch the ArgoCD password during cluster setup. This post goes deeper into the full secrets management architecture.

The Problem: Secret Zero

Every secrets management system has a bootstrapping problem. You need a secret to access your secrets manager. Where does that initial secret come from?

secrets-management-infisical-external-secrets/infisical-eso-architecture diagram
Click to expand
3115 × 895px

The options aren't great:

  • Environment variables on the host: Someone has to set them
  • Cloud IAM: Requires cloud infrastructure and vendor lock-in
  • Mounted files: Still need to get the file there somehow

The pragmatic approach: machine identity credentials stored locally, passed to scripts as environment variables. Not perfect, but contained to one location and never committed to Git.

Why Infisical

I evaluated several options: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and Infisical. For a homelab or small team, Infisical won for a few reasons:

  • Free tier is generous enough for small-scale use
  • Simpler than Vault - no unsealing ceremony, no complex HA setup
  • First-class External Secrets support with a native provider
  • EU hosting option (eu.infisical.com) for data residency
  • Machine identity auth designed for Kubernetes workloads

The setup is: store secrets in Infisical's web UI or CLI, create a machine identity for the cluster, let ESO sync secrets into Kubernetes.

Choosing Your Infisical Region

Infisical offers two hosted regions. Choose based on your data residency requirements:

RegionAPI URLUse Case
US (default)https://app.infisical.comMost users, no specific data residency needs
EUhttps://eu.infisical.comGDPR compliance, European data residency

Throughout this post, examples use the US region (app.infisical.com) as the default. If you need EU hosting, replace the domain in all configuration.

Setting Up Machine Identity

Machine identities in Infisical use Universal Auth - a client ID and secret pair specifically for automated systems. No user login, no MFA prompts, just machine-to-machine authentication.

In Infisical's web UI:

  1. Within a project, go to Access Control > Machine Identities
  2. Click Add Machine Identity to Project
  3. Generate a client ID and client secret
  4. Save these somewhere secure (you'll need them for bootstrap and ongoing management)

Creating a machine identity in Infisical

The identity needs access to read secrets from your project. Scope it to the appropriate environment with read-only access - it doesn't need to modify secrets, just fetch them.

Storing Configuration

Before diving into implementation, establish where configuration lives. I use a config.env file for non-secret values that both scripts and infrastructure-as-code tools can read:

bash
# Infisical Configuration
INFISICAL_API_URL="https://app.infisical.com"    # or https://eu.infisical.com for EU
INFISICAL_PROJECT_SLUG="my-project-slug"
INFISICAL_PROJECT_ID="your-project-uuid"
INFISICAL_ENVIRONMENT="dev"
# Credentials come from environment variables, never stored in files

The actual credentials (INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET) stay in environment variables, set before running any scripts:

bash
export INFISICAL_CLIENT_ID="your-client-id"
export INFISICAL_CLIENT_SECRET="your-client-secret"

This separation keeps configuration in version control while credentials stay out.

Bootstrap: Fetching Initial Secrets

During cluster bootstrap, ESO isn't installed yet. Use the Infisical CLI directly to fetch any secrets needed for initial setup (like an ArgoCD admin password).

Install the CLI:

bash
curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | sudo -E bash
sudo apt-get install -y infisical

Authenticate and fetch a secret:

bash
# Authenticate with machine identity
INFISICAL_TOKEN=$(infisical login \
  --method="universal-auth" \
  --client-id="$INFISICAL_CLIENT_ID" \
  --client-secret="$INFISICAL_CLIENT_SECRET" \
  --domain="https://app.infisical.com" \
  --silent \
  --plain)

# Fetch a specific secret
ARGOCD_PASSWORD=$(infisical secrets get ARGOCD_ADMIN_PASSWORD \
  --path="/argocd" \
  --env="dev" \
  --projectId="$INFISICAL_PROJECT_ID" \
  --domain="https://app.infisical.com" \
  --token="$INFISICAL_TOKEN" \
  --silent \
  --plain)

# Clear token from memory when done
unset INFISICAL_TOKEN

The --plain flag returns just the value, no JSON wrapping. The --silent flag suppresses progress output.

Validate credentials early in your bootstrap script:

bash
validate_environment() {
    if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
        echo "Missing Infisical credentials"
        echo "Please set: export INFISICAL_CLIENT_ID='...' INFISICAL_CLIENT_SECRET='...'"
        exit 1
    fi
}

Installing External Secrets Operator

With the cluster running, install ESO via Helm:

bash
helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm upgrade --install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace \
  --set installCRDs=true \
  --wait

Once installed, ESO watches for ExternalSecret resources and syncs them into Kubernetes Secrets.

Creating the Credentials Secret

ESO needs credentials to authenticate with Infisical. Create a Kubernetes Secret containing the machine identity:

bash
kubectl create namespace platform-secrets

kubectl create secret generic infisical-credentials \
  --namespace platform-secrets \
  --from-literal=client-id="$INFISICAL_CLIENT_ID" \
  --from-literal=client-secret="$INFISICAL_CLIENT_SECRET"

Or declaratively with Terraform/OpenTofu:

hcl
resource "kubernetes_secret" "infisical_credentials" {
  metadata {
    name      = "infisical-credentials"
    namespace = "platform-secrets"
  }

  data = {
    "client-id"     = var.infisical_client_id
    "client-secret" = var.infisical_client_secret
  }
}

Configuring the ClusterSecretStore

A ClusterSecretStore tells ESO how to reach Infisical. This is cluster-wide, so any namespace can reference it:

yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: infisical-cluster-secretstore
spec:
  provider:
    infisical:
      hostAPI: https://app.infisical.com  # or https://eu.infisical.com for EU

      auth:
        universalAuthCredentials:
          clientId:
            name: infisical-credentials
            key: client-id
            namespace: platform-secrets
          clientSecret:
            name: infisical-credentials
            key: client-secret
            namespace: platform-secrets

      secretsScope:
        projectSlug: my-project-slug
        environmentSlug: dev
        secretsPath: "/"

Apply it:

bash
kubectl apply -f cluster-secret-store.yaml

Using the Terraform Provider

If you manage infrastructure with Terraform/OpenTofu, you can read secrets directly from Infisical. This is useful for configuring other providers (like ArgoCD) that need credentials.

hcl
terraform {
  required_providers {
    infisical = {
      source  = "Infisical/infisical"
      version = "~> 0.15"
    }
  }
}

provider "infisical" {
  host = "https://app.infisical.com"  # or https://eu.infisical.com for EU
  auth = {
    universal = {
      client_id     = var.infisical_client_id
      client_secret = var.infisical_client_secret
    }
  }
}

Fetch secrets as data sources:

hcl
data "infisical_secrets" "argocd" {
  env_slug     = "dev"
  workspace_id = var.infisical_project_id
  folder_path  = "/argocd"
}

# Use in other provider configurations
provider "argocd" {
  password = data.infisical_secrets.argocd.secrets["ARGOCD_ADMIN_PASSWORD"].value
}

This lets you bootstrap providers that need secrets without hardcoding values or using separate secret files.

Important: State file security

When Terraform/OpenTofu reads secrets, those values end up in the state file. This is a security consideration:

secrets-management-infisical-external-secrets/terraform-state-security diagram
Click to expand
1568 × 441px
  • OpenTofu supports native client-side state encryption (since 1.7) using AES-GCM with keys from PBKDF2, AWS KMS, GCP KMS, or OpenBao
  • Terraform does not have native state encryption - you must rely on encrypted backends (S3 with SSE, Terraform Cloud, etc.)

If you're storing secrets in state, OpenTofu's encryption feature is worth considering. Otherwise, ensure your state backend is properly secured and access-controlled.

ExternalSecret Patterns

With the ClusterSecretStore configured, applications request secrets via ExternalSecret resources. These live in Git - they contain references to secrets, not the values themselves.

Basic pattern - single secret:

yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: redis-credentials
  namespace: redis
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: infisical-cluster-secretstore
    kind: ClusterSecretStore
  target:
    name: redis-credentials
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: "/redis/REDIS_PASSWORD"

Multiple secrets in one resource:

yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: minio-credentials
  namespace: minio
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: infisical-cluster-secretstore
    kind: ClusterSecretStore
  target:
    name: minio-credentials
  data:
    - secretKey: rootUser
      remoteRef:
        key: "/minio/MINIO_ROOT_USER"
    - secretKey: rootPassword
      remoteRef:
        key: "/minio/MINIO_ROOT_PASSWORD"

Templated secrets with labels:

yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: gitlab-repo-credentials
  namespace: argocd
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: infisical-cluster-secretstore
    kind: ClusterSecretStore
  target:
    name: gitlab-repo
    creationPolicy: Owner
    template:
      metadata:
        labels:
          argocd.argoproj.io/secret-type: repository
      data:
        type: git
        url: https://gitlab.com/your-org/your-repo.git
        username: "{{ .username }}"
        password: "{{ .password }}"
  data:
    - secretKey: username
      remoteRef:
        key: "/gitlab/DEPLOY_TOKEN_USERNAME"
    - secretKey: password
      remoteRef:
        key: "/gitlab/DEPLOY_TOKEN_PASSWORD"

The template feature lets you construct complex secrets combining static values with fetched values.

Related: See GitLab Runner on Kubernetes for a practical example using ExternalSecrets for runner authentication.

Organising Secrets in Infisical

Organise secrets by path for clarity:

PathPurpose
/argocd/ArgoCD admin credentials
/gitlab/GitLab deploy tokens, runner tokens
/redis/Redis authentication
/minio/Object storage credentials
/grafana/Monitoring credentials
/cert-manager/DNS challenge credentials

The pattern: /<application>/<SECRET_NAME>. Clear, searchable, and easy to scope access.

Types of secrets to store:

  • Service credentials: Database passwords, cache auth, object storage keys
  • Platform tokens: Deploy tokens, runner registration tokens
  • Cloud credentials: IAM keys for cert-manager DNS validation
  • Application secrets: API keys, admin passwords

The Refresh Cycle

ESO polls on an interval, not continuously. Use refreshInterval: 15m for most secrets:

  • Secret rotation takes up to 15 minutes to propagate
  • Reduces API calls to Infisical
  • Acceptable latency for most use cases

Lower the interval for critical secrets requiring faster rotation. Increase it for static secrets that rarely change.

Security Considerations

What's protected:

  • No secrets in Git - ExternalSecrets reference paths, not values
  • Machine identity credentials never committed
  • Infisical handles encryption at rest and in transit

What's not protected:

  • Kubernetes Secrets are base64 encoded, not encrypted (unless you enable encryption at rest)
  • Anyone with cluster access can read synced secrets
  • The secret zero problem is pushed to the operator, not eliminated

Recommendations:

  • Enable Kubernetes encryption at rest for Secrets
  • Use RBAC to restrict secret access by namespace
  • Consider Sealed Secrets or SOPS for secrets that must be in Git
  • Audit Infisical access logs periodically

The Complete Flow

Putting it all together:

secrets-management-infisical-external-secrets/complete-flow diagram
Click to expand
314 × 1285px
  1. Setup (one-time): Create machine identity in Infisical, store client ID/secret locally
  2. Bootstrap: Script authenticates via CLI, fetches initial secrets, installs cluster components
  3. ESO Install: External Secrets Operator deployed to cluster
  4. Credentials: Create the infisical-credentials Kubernetes Secret
  5. ClusterSecretStore: Configure ESO to connect to Infisical
  6. ExternalSecrets: Deploy manifests that reference secrets by path
  7. Sync: ESO watches ExternalSecrets, creates Kubernetes Secrets
  8. Consumption: Pods mount secrets normally - they don't know the source

Applications see standard Kubernetes Secrets. ESO is the bridge.

What I'd Change

Secret versioning: Infisical supports secret versions. Pinning to specific versions would add safety during rotations.

Backup strategy: If Infisical is unavailable, ESO can't refresh secrets. Existing secrets persist, but new deployments might fail. A backup secret store would help.

Audit integration: Infisical has audit logs. Shipping these to your logging system would add visibility.

Workload identity: On cloud providers, workload identity (GKE, EKS IAM roles) eliminates the secret zero problem entirely.


This is Part 5 of the Homelab Kubernetes Series, covering secrets management patterns for Kubernetes. See also: GitLab Runner on Kubernetes for a practical example of using External Secrets.

← Back to all posts