Posted on:September 17, 2026 at 12:00 AM

Infisical End to End: Self-Hosting, the CLI, Docker Compose, and Kubernetes

Infisical End to End: Self-Hosting, the CLI, Docker Compose, and Kubernetes

Infisical End to End: Self-Hosting, the CLI, Docker Compose, and Kubernetes

Most projects start with a .env file, and most projects never leave it. The file gets copied to a server over scp, pasted into a Slack thread when a teammate joins, and forgotten on three laptops. Nobody knows which values are current, and rotating a leaked key means hunting down every copy.

This post walks through replacing that with Infisical — self-hosted, so the secrets stay on your own machine — and then actually wiring it into the two places secrets are usually consumed: a Docker Compose host and a Kubernetes cluster.

Path through this post:

  1. What Infisical is
  2. Pick an install path
  3. Self-host with Docker Compose
  4. Install the CLI
  5. Bootstrap a project from a local .env
  6. Use the secrets on a Compose server — replacing the server-side .env
  7. Use the secrets in Kubernetes

Versions referenced below: Infisical server infisical/infisical:latest on the published docker-compose.prod.yml, and the Kubernetes operator Helm chart v0.11.9 with the v1beta1 CRDs. Infisical moves fast — check the version numbers against the docs before pasting.


1. What is Infisical?

Infisical is an open-source secret management platform. At its core it is a server that stores secrets encrypted at rest, plus a set of clients — a CLI, SDKs, a Kubernetes operator, CI integrations — that fetch those secrets at runtime so they never have to live in a file next to your code.

The mental model is a three-level tree:

Organization
└── Project  (e.g. "orders-api")
    ├── Environment: dev
    ├── Environment: staging
    └── Environment: prod
        └── Folder path: /   /database   /stripe
            └── SECRET_KEY = value

A secret is addressed by project + environment slug + folder path + key. Every client you will see below — infisical run, the operator CRD, the SDKs — takes some version of those four coordinates.

What you actually get over a .env file

Problem with .envWhat Infisical does
No idea who has a copyAccess control per project and environment, plus an audit log of every read
Rotation means chasing filesChange the value once; clients pick it up on their next fetch or restart
Secrets end up in gitNothing to commit — the project config file that is committed holds no values
Machines need a human to hand them a fileMachine identities authenticate on their own with a client ID and secret
Dev and prod values driftSame key, different environment — one place to compare them

It also does certificate management, dynamic secrets (short-lived database credentials minted on demand), and secret scanning. This post sticks to static secrets, which is what most teams need first.

How it compares

  • HashiCorp Vault — far more powerful, far more operational surface. If you are not already running Vault and you mostly want “a good place to keep API keys,” Vault is a lot of machine to adopt.
  • Doppler / AWS Secrets Manager / GCP Secret Manager — comparable ergonomics, but hosted only. Infisical’s differentiator here is that the same product self-hosts cleanly.
  • SOPS + age — excellent if you want secrets in git, encrypted. Different philosophy: SOPS has no server, no audit log, and no runtime API. It is a good fit for GitOps, a poor fit for “rotate this key across 12 services right now.”

2. Pick an install path

There are two decisions, and only the second one is interesting.

Server: Infisical Cloud (https://app.infisical.com, free tier available) or self-hosted. Everything in this post works with either — the only difference is that self-hosted clients need to be told where the server is. Sections 3 onward assume self-hosted.

Self-hosting method:

MethodGood forTrade-off
Docker ComposeA single host, homelab, small teamSingle node, no HA — the documented starting point
Kubernetes (Helm)You already run KubernetesMore moving parts; Postgres and Redis are on you
Standalone DockerEmbedding in an existing orchestratorYou supply Postgres and Redis yourself

Docker Compose is what section 3 covers. Note the distinction that trips people up: self-hosting Infisical on Compose (section 3) and consuming Infisical secrets from a Compose app (section 6) are unrelated problems, and your Infisical server does not have to live on the same host as the apps it serves.

Prerequisites

  • A host with Docker and the Compose plugin (docker compose version works)
  • ~2 GB RAM free; the stack is a Node backend, Postgres, and Redis
  • A DNS name and TLS if anything other than your laptop will use it

3. Self-host with Docker Compose

3.1 Fetch the files

mkdir -p ~/infisical && cd ~/infisical

curl -o docker-compose.prod.yml \
  https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml

curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example

The Compose file defines three services — backend (infisical/infisical:latest), db (postgres:14-alpine), and redis — with named volumes pg_data and redis_data, and publishes the backend as 80:8080.

3.2 Generate real keys

The example .env ships with sample values that are explicitly marked as development-only. Replace them before anything else:

echo "ENCRYPTION_KEY=$(openssl rand -hex 16)"
echo "AUTH_SECRET=$(openssl rand -base64 32)"

Two keys, two different jobs:

VariableJobFormat
ENCRYPTION_KEYEncrypts every secret at rest in Postgres32 hex chars (rand -hex 16)
AUTH_SECRETSigns session and JWT tokensbase64, 32 bytes

Back up ENCRYPTION_KEY now, outside this host. A Postgres dump without it is unreadable. Restoring the database onto a new machine with a freshly generated key gets you a working login page and zero recoverable secrets. Rotating it later is a documented but deliberate procedure — not something to improvise during an incident.

3.3 Fill in the rest of .env

# --- required ---
ENCRYPTION_KEY=<the 32 hex chars from above>
AUTH_SECRET=<the base64 string from above>

POSTGRES_USER=infisical
POSTGRES_PASSWORD=<a long random password>
POSTGRES_DB=infisical
DB_CONNECTION_URI=postgres://infisical:<same password>@db:5432/infisical

REDIS_URL=redis://redis:6379

# The URL users and clients will actually hit. Not "localhost" if it's a server.
SITE_URL=https://secrets.example.com

# --- optional but recommended: SMTP, for invites and password resets ---
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=
SMTP_FROM_NAME=Infisical

Two things worth getting right the first time:

  • DB_CONNECTION_URI must match POSTGRES_*. The hostname is db — the Compose service name — not localhost.
  • SITE_URL must be the externally reachable URL. It ends up in invite links and email, and a wrong value produces links nobody can click.

Lock the file down, since it now holds the key to everything:

chmod 600 .env

3.4 Start it

docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps

Checkpoint: all three containers are running, and db reports healthy. If backend restarts in a loop, it is almost always DB_CONNECTION_URI:

docker compose -f docker-compose.prod.yml logs -f backend

3.5 First login

Open http://<host>/ — the backend is published on port 80. The first account to sign up becomes the instance admin, so do this immediately after the stack comes up and before the host is reachable from anywhere you don’t control.

Then: create an organization, create your first project, and note the project ID from the project’s settings page. Every machine client below needs it.

3.6 Put TLS in front of it

The Compose stack speaks plain HTTP. Anything beyond a laptop needs a reverse proxy — Caddy, nginx, or Cloudflare Tunnel — terminating TLS and forwarding to port 80 on the host. Set SITE_URL to the https:// address and restart the backend afterward.


4. Install the CLI

The CLI is the tool you will use most: it bootstraps projects, injects secrets into processes, and authenticates machines.

# macOS
brew install infisical/get-cli/infisical

# Debian / Ubuntu
curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | sudo -E bash
sudo apt-get update && sudo apt-get install -y infisical

# RHEL / CentOS / Amazon Linux
curl -1sLf 'https://artifacts-cli.infisical.com/setup.rpm.sh' | sudo -E bash
sudo yum install infisical

# Any platform with Node
npm install -g @infisical/cli
infisical --version

On servers and in CI, pin the version rather than tracking latest, so a reinstall doesn’t silently change behavior.

Pointing the CLI at your own server

This is the single most common stumbling block with a self-hosted instance: the CLI defaults to Infisical Cloud. If you skip this step, infisical login will cheerfully authenticate you against app.infisical.com and then report that your project does not exist.

Resolution order, highest precedence first:

WhereExample
--domain flaginfisical login --domain="https://secrets.example.com"
INFISICAL_DOMAIN env varexport INFISICAL_DOMAIN=https://secrets.example.com
domain field in .infisical.jsonpins the instance per project
Defaulthttps://app.infisical.com

(The older INFISICAL_API_URL is still honored; INFISICAL_DOMAIN wins when both are set.)

Use the base URL — no /api suffix. The simplest thing is to export it once in your shell profile:

export INFISICAL_DOMAIN="https://secrets.example.com"
infisical login

For a human on a laptop, infisical login opens a browser and stores the credential in the OS keychain. Machines use a different mechanism — section 6.


5. Bootstrap a project from a local .env

You have an existing project with a .env full of values. The goal: get them into Infisical without typing each one.

From your project root:

cd ~/code/orders-api
infisical init

This writes .infisical.json:

{
  "workspaceId": "63ee5410a45f7a1ed39ba118",
  "defaultEnvironment": "dev",
  "domain": "https://secrets.example.com"
}

Commit this file. It contains no secrets — only which project and instance this directory belongs to. Adding the domain field means teammates don’t each have to export INFISICAL_DOMAIN.

While you are here, make sure the actual secrets can’t follow it into git:

grep -qxF '.env' .gitignore || echo '.env' >> .gitignore

5.2 Push the existing .env up

infisical secrets set takes any number of KEY=value pairs, so a whole file goes up in one call:

#!/usr/bin/env bash
# import-env.sh — push a local dotenv file into an Infisical environment
set -euo pipefail

ENV_FILE="${1:-.env}"
ENV_SLUG="${2:-dev}"
SECRET_PATH="${3:-/}"

[[ -f "$ENV_FILE" ]] || { echo "no such file: $ENV_FILE" >&2; exit 1; }

# Keep KEY=value lines; drop comments, blanks, and any `export ` prefix.
mapfile -t PAIRS < <(
  sed -e 's/^[[:space:]]*export[[:space:]]\+//' "$ENV_FILE" \
    | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' \
    | sed -e 's/=[[:space:]]*"\(.*\)"[[:space:]]*$/=\1/' \
          -e "s/=[[:space:]]*'\(.*\)'[[:space:]]*\$/=\1/"
)

(( ${#PAIRS[@]} )) || { echo "no KEY=value lines found in $ENV_FILE" >&2; exit 1; }

echo "Importing ${#PAIRS[@]} secrets into env=$ENV_SLUG path=$SECRET_PATH"
printf '  %s\n' "${PAIRS[@]%%=*}"

infisical secrets set "${PAIRS[@]}" --env="$ENV_SLUG" --path="$SECRET_PATH"
chmod +x import-env.sh
./import-env.sh .env dev /
./import-env.sh .env.production prod /

The script strips export prefixes and surrounding quotes, which is where naive one-liners usually go wrong. It intentionally does not handle multi-line values — see the note below.

Checkpoint:

infisical secrets --env=dev

5.3 Things to know while importing

Multi-line values and file contents (PEM keys, service-account JSON) should be read from the file rather than pasted:

infisical secrets set PRIVATE_KEY=@./private.pem
infisical secrets set GCP_SA_JSON=@./service-account.json

A literal @ at the start of a value has to be escaped: infisical secrets set EMAIL="\@example.com".

Organize with folders rather than one flat list, once you pass twenty or so keys:

infisical secrets folders create --name=database --env=dev
infisical secrets set DB_PASSWORD=... --env=dev --path="/database"

Use --tag to mark subsets you will want to fetch separately later:

infisical secrets set STRIPE_KEY=... --tag=payments --env=prod

5.4 Run locally without a .env at all

This is the payoff for developers:

infisical run --env=dev -- npm run dev

infisical run fetches the secrets, injects them as environment variables into the child process, and never writes them to disk. Useful flags:

FlagEffect
--envEnvironment slug; defaults to dev
--pathFolder to pull from; repeatable (--path=/common --path=/api)
--recursiveInclude subfolders under --path
--watchRestart the command when a secret changes upstream
--commandRun a shell string instead of an argv (--command="npm run build && npm start")
--projectIdRequired when authenticating as a machine identity rather than a user

Once this works, delete the local .env. That is the whole point.


6. Use the secrets on a Compose server

Now the server side. You have a host running docker compose up -d against a .env file that you copied there by hand, and you want that file gone.

6.1 First: a machine identity

A server cannot do a browser login. It authenticates as a machine identity using Universal Auth — a client ID and client secret pair.

In the UI: Organization → Access Control → Machine Identities → Create. Give it a name (orders-api-prod), then Add Client Secret and copy both values — the secret is shown once.

Then grant it access to the project: Project → Access Control → Machine Identities → Add, with a read-only role. A deploy host has no business writing secrets.

One identity per service per environment. Shared credentials are how a staging compromise becomes a production one.

On the server:

export INFISICAL_DOMAIN="https://secrets.example.com"

export INFISICAL_TOKEN=$(infisical login \
  --method=universal-auth \
  --client-id="<client-id>" \
  --client-secret="<client-secret>" \
  --plain --silent)

--plain --silent prints just the JWT, which makes it usable in a $(...) like this. The token is short-lived (30 days by default) and re-minted from the client ID/secret whenever you need a new one.

Now pick one of three patterns.

6.2 Pattern A — generate .env at deploy time

The smallest change to an existing setup: keep the .env file, but stop maintaining it by hand.

#!/usr/bin/env bash
# deploy.sh
set -euo pipefail
umask 077   # so the .env we write is 600, not 644

export INFISICAL_DOMAIN="https://secrets.example.com"
PROJECT_ID="<your-project-id>"

INFISICAL_TOKEN=$(infisical login \
  --method=universal-auth \
  --client-id="$UA_CLIENT_ID" \
  --client-secret="$UA_CLIENT_SECRET" \
  --plain --silent)
export INFISICAL_TOKEN

infisical export \
  --projectId="$PROJECT_ID" \
  --env=prod \
  --path=/ \
  --format=dotenv > .env

docker compose up -d

--format accepts dotenv, dotenv-export, json, yaml, and csv.

What this buys you: one source of truth, and rotation is a redeploy instead of an scp.

What it doesn’t: the plaintext file is still on disk, and Compose’s .env also feeds ${VAR} interpolation in the Compose file itself — so a secret named POSTGRES_PASSWORD will silently substitute into your Compose file too, whether or not you meant it to. Honest assessment: this is a real improvement over a hand-copied file, but it is the weakest of the three.

You still need the client ID and secret on the host. Put them in a root-owned /etc/infisical.env with mode 600, or in the systemd unit’s EnvironmentFile. There is always one bootstrap credential; the goal is to have exactly one, and to have it be revocable from a UI.

6.3 Pattern B — never write the file

infisical run can wrap the Compose CLI itself:

infisical run \
  --projectId="<your-project-id>" \
  --env=prod \
  -- docker compose up -d

The secrets land in the environment of the docker compose process. Compose then uses them for two things:

1. ${VAR} interpolation in the Compose file:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

2. Passthrough entries — a key with no value is taken from the shell:

services:
  api:
    image: ghcr.io/example/orders-api:1.4.2
    environment:
      - DATABASE_URL
      - STRIPE_SECRET_KEY
      - JWT_SIGNING_KEY

That second form is the one to reach for. It names exactly which secrets reach which container, and it reads as documentation.

Two gotchas:

  • Shell environment beats the .env file for interpolation, so during a migration a leftover .env won’t override what infisical run injects — but it also won’t be obviously unused. Delete it once you’ve cut over.
  • docker compose up -d returns immediately, and containers keep the environment they were started with. Secrets change → you must re-run the command. A rotation is not picked up by running containers on its own.

6.4 Pattern C — the CLI inside the container

Highest isolation: each container fetches its own secrets at startup, so nothing on the host ever holds the full set.

FROM node:22-alpine

RUN apk add --no-cache curl \
 && curl -1sLf 'https://artifacts-cli.infisical.com/setup.apk.sh' | sh \
 && apk add --no-cache infisical

WORKDIR /app
COPY . .
RUN npm ci --omit=dev

ENTRYPOINT ["infisical", "run", "--projectId=<your-project-id>", "--env=prod", "--"]
CMD ["node", "server.js"]
services:
  orders-api:
    build: .
    environment:
      INFISICAL_TOKEN: ${INFISICAL_TOKEN}
      INFISICAL_DOMAIN: ${INFISICAL_DOMAIN}
export INFISICAL_DOMAIN="https://secrets.example.com"
export INFISICAL_TOKEN=$(infisical login --method=universal-auth \
  --client-id="$UA_CLIENT_ID" --client-secret="$UA_CLIENT_SECRET" --plain --silent)

docker compose up -d --build

Do not set INFISICAL_DOMAIN to a localhost URL here. Inside the container that resolves to the container itself. If Infisical is running on the same host, use host.docker.internal (or put both stacks on a shared Docker network and use the service name).

With multiple services on different permissions, give each its own identity and its own variable — INFISICAL_TOKEN_WEB, INFISICAL_TOKEN_API — and map them per service.

6.5 Which pattern

A: export to .envB: infisical run --C: CLI in container
Plaintext on host diskYesNoNo
Change to app imageNoneNoneDockerfile + CLI
Per-service scopingNoManual, per keyYes, per identity
Works with ${VAR} interpolationYesYesNo
Good forMigrating an existing hostMost Compose setupsMulti-tenant hosts

Start at B. Go to C when services need different permissions. Use A only when something in your stack genuinely requires a file.


7. Use the secrets in Kubernetes

In Kubernetes you don’t inject secrets into processes — you let an operator sync Infisical secrets into native Kubernetes Secret objects, and consume those the way you already do.

7.1 Install the operator

helm repo add infisical-helm-charts \
  'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
helm repo update

helm install infisical-secrets-operator \
  infisical-helm-charts/secrets-operator \
  --namespace infisical-operator-system \
  --create-namespace
kubectl get pods -n infisical-operator-system
kubectl get crds | grep infisical

The current API is secrets.infisical.com/v1beta1, with three CRDs that split responsibilities cleanly:

CRDAnswers
InfisicalConnectionWhich Infisical instance, and how to trust its TLS
InfisicalAuthWhich machine identity, and which auth method
InfisicalStaticSecretWhich secrets sync where, and how often

The older v1alpha1 InfisicalSecret — one resource with connection, auth, and sync inlined — is deprecated. InfisicalPushSecret and InfisicalDynamicSecret still live under v1alpha1.

7.2 The credentials Secret

There is a chicken-and-egg problem here: the operator needs a credential to fetch credentials. Create a machine identity as in 6.1, then:

kubectl create secret generic universal-auth-credentials \
  --namespace=orders \
  --from-literal=clientId="<client-id>" \
  --from-literal=clientSecret="<client-secret>"

7.3 The three resources

# 1. Where the Infisical instance lives
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalConnection
metadata:
  name: self-hosted-infisical
  namespace: orders
spec:
  address: https://secrets.example.com
---
# 2. How to authenticate to it
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalAuth
metadata:
  name: orders-auth
  namespace: orders
spec:
  infisicalConnectionRef:
    name: self-hosted-infisical
    namespace: orders
  method: universal
  universal:
    clientIdRef:
      name: universal-auth-credentials
      namespace: orders
      key: clientId
    clientSecretRef:
      name: universal-auth-credentials
      namespace: orders
      key: clientSecret
---
# 3. What to sync, and where to put it
apiVersion: secrets.infisical.com/v1beta1
kind: InfisicalStaticSecret
metadata:
  name: orders-api-secrets
  namespace: orders
spec:
  infisicalAuthRef:
    name: orders-auth
    namespace: orders
  syncOptions:
    refreshInterval: 60s
  sources:
    - projectId: <your-project-id>
      environmentSlug: prod
      secretPath: /
  targets:
    - name: orders-api-secrets
      namespace: orders
      kind: Secret
      creationPolicy: Owner
kubectl apply -f infisical.yaml

Checkpoint:

kubectl get infisicalconnection,infisicalauth,infisicalstaticsecret -n orders
kubectl get secret orders-api-secrets -n orders -o jsonpath='{.data}' | jq 'keys'

The printer columns show Ready on the connection and auth, and Synced on the static secret. When something is wrong, kubectl describe infisicalstaticsecret orders-api-secrets -n orders puts the reason in the conditions.

Use address: https://secrets.example.com without a /api suffix — the operator appends it. For an instance behind a private CA, add TLS settings under spec.tls on the connection rather than disabling verification.

7.4 Field notes on the spec

sources is a list, so one target can merge several places — shared config plus service-specific keys:

sources:
  - projectId: <project-id>
    environmentSlug: prod
    secretPath: /common
  - projectId: <project-id>
    environmentSlug: prod
    secretPath: /orders
    recursive: true
    tagSlugs: ["payments"]

Each source takes either projectId or projectSlug, plus environmentSlug and secretPath; recursive pulls subfolders, and tagSlugs filters.

targets is also a list, and can write a ConfigMap instead of a Secret (kind: ConfigMap — for non-sensitive config only). creationPolicy matters:

creationPolicyBehavior
OwnerOperator owns the Secret; deleting the CR garbage-collects it. Default choice.
OrphanThe Secret outlives the CR. For migrations and for secrets other controllers touch.

syncOptions.refreshInterval is the poll period (60s is a sane default). instantUpdates: true opts into push-based updates so a rotation propagates without waiting out the interval.

targets[].template reshapes the output when your app wants a specific format rather than one key per secret — a rendered config file, a DATABASE_URL assembled from parts. Set engineVersion: v1 and provide Go templates under data.

7.5 Consume it, and reload on change

apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
  namespace: orders
  annotations:
    secrets.infisical.com/auto-reload: "true"
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orders-api
  template:
    metadata:
      labels:
        app: orders-api
    spec:
      containers:
        - name: api
          image: ghcr.io/example/orders-api:1.4.2
          envFrom:
            - secretRef:
                name: orders-api-secrets

The secrets.infisical.com/auto-reload: "true" annotation is the part people miss. Environment variables are read once at process start, so without it a rotated secret sits in the Kubernetes Secret while every running pod keeps using the old value until someone happens to redeploy. With it, the operator rolls the Deployment when the synced Secret changes.

7.6 In-cluster auth without static credentials

Universal Auth means a client ID and secret sitting in a Kubernetes Secret — better than a .env, but still a long-lived credential you have to rotate.

If your cluster can do better, InfisicalAuth supports other methods, each keyed on a workload identity the platform already vouches for:

methodUses
kubernetesA ServiceAccount token from this cluster
awsIamThe node/pod IAM role
gcpIdTokenWorkload Identity
azureAzure managed identity
ldapLDAP bind

method: kubernetes is the natural fit for a self-hosted instance running next to the cluster — no static secret to rotate, and identity is scoped to a ServiceAccount:

spec:
  method: kubernetes
  kubernetes:
    identityIdRef:
      name: infisical-identity-id
      namespace: orders
      key: identityId
    serviceAccountRef:
      name: orders-api
      namespace: orders

Worth the extra setup for production. Start with universal auth to prove the pipeline works, then switch.

7.7 One thing the operator does not solve

The synced object is an ordinary Kubernetes Secret — base64, not encryption. Anyone with get secrets in that namespace can read it. Infisical fixes the distribution problem, not Kubernetes’ storage model. Still worth doing: encryption at rest on etcd, and RBAC that doesn’t hand out namespace-wide secret reads.


Where this leaves you

ContextMechanismSecrets on disk
Local developmentinfisical run -- npm run devNone
Compose serverinfisical run -- docker compose up -dNone
KubernetesOperator → native Secret → envFromCluster only
CIMachine identity + INFISICAL_TOKENNone

The through-line is that every client authenticates as itself and fetches what it is allowed to fetch. Rotating a key becomes one edit in one UI, and the blast radius of a leaked credential is one identity you can revoke.

The honest caveats: you are now running a stateful service that everything else depends on, so its Postgres needs a backup strategy and its uptime is your uptime. ENCRYPTION_KEY is a single point of total data loss — treat the backup of that one string as seriously as the database itself. And in Kubernetes, secrets still land in etcd as base64. Infisical raises the floor considerably; it does not remove the need to think.

References

Related Posts