Containers

Install Infisical Secrets Manager: Docker or Linux Package

Infisical is a self-hosted secrets manager. It stores the credentials your applications need and hands them to a process at runtime, so nothing sensitive has to sit in a file on the box. The reason to run one is the count: your production database password is in a .env on the app server, in a CI variable, in a Slack thread from the day the service broke, and in somebody’s shell history. Nobody rotates it because nobody can find every copy.

Original content from computingforgeeks.com - post 170706

This guide shows how to install Infisical on your own server two ways, with Docker Compose and with the native Linux package, puts Nginx and a Let’s Encrypt certificate in front of it, then walks the full path a real secret takes: create a project, store the secret, give an application its own machine identity, and inject the value into a systemd service that never writes it to disk. Every command below was run on Ubuntu 24.04 LTS, Ubuntu 26.04 LTS and Rocky Linux 10 with SELinux enforcing, in August 2026.

How the pieces fit together

Infisical ships as one stateless service, infisical-core, that serves both the API and the web UI on port 8080. It keeps nothing locally. PostgreSQL holds the encrypted secret rows and Redis holds background job queues, distributed locks, and coordination state, which is why Redis is a required datastore here and not an optional cache.

The encryption boundary matters more than the topology. Secret values are encrypted with the instance ENCRYPTION_KEY, which lives in the server config and never travels to a client. Clients authenticate, ask for a scoped set of secrets, and receive plaintext over TLS. That makes the reverse proxy in front of the service a security control, not decoration.

Self-hosted Infisical architecture diagram showing nginx TLS termination, infisical-core on port 8080, PostgreSQL and Redis

Two deployment shapes come out of that. Docker Compose brings its own PostgreSQL and Redis containers, so one host gives you a working instance in about five minutes. The Linux package installs only the service and expects you to point it at databases you already run, which is what you want when PostgreSQL lives on a managed service or a dedicated box. Both are covered below and both were installed for this guide.

Prerequisites and honest sizing

Sizing follows secret operation volume, not user count. Infisical’s own guidance allocates 2 to 4 CPU cores and 4 to 8 GB of memory per service instance, because the service is stateless and scales horizontally rather than vertically. PostgreSQL is where growth actually shows up: their small deployment template is 2 vCPU, 8 GB RAM and 100 GB disk, and the disk figure is driven by audit log retention rather than by the secrets themselves, which are tiny. Redis wants 2 vCPU and 4 GB, with eviction set to noeviction and persistence enabled, since losing the queue state loses in-flight work.

The hosts behind this guide ran 2 vCPU and 4 GB of RAM with a 30 GB disk. That is a floor for following along, not a production recommendation. On that box the backend container settled at 883 MiB resident with PostgreSQL at 200 MiB, so 4 GB is comfortable for a lab and thin for anything real.

One number that surprises people: the infisical/infisical image is 3.4 GB pulled. Size the root volume with that in mind before you point Compose at a 10 GB VPS disk.

You also need a DNS A record pointing at the server and port 80 reachable from the internet for the certificate step, plus port 443 open afterwards. Any DNS provider works.

Step 1: Set reusable shell variables

Every command in this guide reads from the same three variables, so you edit one block and paste the rest as is. Export them at the top of your SSH session:

export SITE_DOMAIN="secrets.example.com"
export ADMIN_EMAIL="[email protected]"
export INFISICAL_DIR="/opt/infisical"

Swap in your real hostname and email, then confirm they are set before anything else runs. Empty variables here produce a certificate request for nothing and an Nginx vhost that matches no host:

echo "Domain: ${SITE_DOMAIN}"
echo "Email:  ${ADMIN_EMAIL}"
echo "Dir:    ${INFISICAL_DIR}"

These values live only in the current shell. Re-export them if you reconnect or drop into sudo -i.

Step 2: Install Infisical with Docker Compose on Ubuntu or Debian

This path suits a single host that owns its own database. The commands below are Debian family; on Rocky Linux either swap in the RHEL Docker CE repository or use the package install in Step 3, which is the better fit there anyway. Start with Docker from the official repository, since distribution packages lag badly:

sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Confirm both the engine and the Compose plugin answer:

docker --version && docker compose version

On the test host that printed:

Docker version 29.7.2, build a7dcaa6
Docker Compose version v5.4.0

Now pull the production Compose file and the sample environment file into a directory of your own:

sudo mkdir -p "${INFISICAL_DIR}"
cd "${INFISICAL_DIR}"
sudo curl -o docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml
sudo curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example

Those two files are everything the stack needs. The next step is the one people skip.

Replace the sample keys before the first start

Open the file you just downloaded and read the top of it. The sample ships with a working ENCRYPTION_KEY and AUTH_SECRET already filled in, and those values are published in a public repository. An instance started on the defaults encrypts every secret you store with a key that anyone can read. Do not skip this.

Generate real values and write them in, along with a real database password and your public URL:

ENCRYPTION_KEY=$(openssl rand -hex 16)
AUTH_SECRET=$(openssl rand -base64 32)
PG_PASS=$(openssl rand -hex 24)

sudo sed -i "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|"     .env
sudo sed -i "s|^AUTH_SECRET=.*|AUTH_SECRET=${AUTH_SECRET}|"             .env
sudo sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${PG_PASS}|"     .env
sudo sed -i "s|^SITE_URL=.*|SITE_URL=https://${SITE_DOMAIN}|"           .env
sudo chmod 600 .env

SITE_URL must be the absolute URL users will type, protocol included. Password reset links, invitations and OAuth redirects are all built from it, so an instance that says http://localhost:8080 emits broken links the moment anyone else uses it.

Bind the service to loopback, not to every interface

The stock Compose file publishes the backend as 80:8080, which means Docker opens port 80 on every interface and bypasses UFW while doing it. Nginx needs that port anyway, so move the service to loopback:

sudo sed -i "s|^      - 80:8080|      - 127.0.0.1:8080:8080|" docker-compose.prod.yml

While you are in that file, note the image line reads infisical/infisical:latest with a comment telling you to pin it. Take the advice on anything you care about, because pull_policy: always means the next restart silently upgrades you. Start the stack:

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

Three containers come up, and the backend waits for the database health check before it starts. Compose also warns that the upstream file still carries an obsolete version attribute, which is noise rather than a problem. List them:

sudo docker compose -f docker-compose.prod.yml ps --format "table {{.Name}}\t{{.Image}}\t{{.Status}}"

All three should read Up, with the database also reporting healthy:

NAME                  IMAGE                        STATUS
infisical-backend     infisical/infisical:latest   Up 36 seconds
infisical-db          postgres:14-alpine           Up 37 seconds (healthy)
infisical-dev-redis   redis                        Up 37 seconds

Ask the API whether it is alive before you touch Nginx:

curl -s http://127.0.0.1:8080/api/status

The status endpoint reports the instance configuration, which is handy now and worth remembering later, because it answers without authentication:

{"date":"2026-08-12T20:00:44.538Z","message":"Ok","emailConfigured":false,"inviteOnlySignup":true,"redisConfigured":true,"secretScanningConfigured":false,"auditLogStorageDisabled":false,"maxIdentityAccessTokenTTL":7776000}

With that responding, the Compose path is done and you can skip ahead to the Nginx step.

Step 3: Install Infisical from the Linux package

The native package is the better fit when PostgreSQL and Redis already exist somewhere else, or when containers are not welcome on the host. It installs the service plus a control utility, infisical-ctl, and expects you to supply both databases. PostgreSQL 14 or newer is required.

Add the repository and install the package

On Ubuntu and Debian, add the repository first:

curl -1sLf 'https://artifacts-infisical-core.infisical.com/setup.deb.sh' | sudo -E bash
sudo apt update

Check what it is offering before you install anything:

apt-cache policy infisical-core

The candidate should be the current release:

infisical-core:
  Installed: (none)
  Candidate: 0.162.19-1
  Version table:
     0.162.19-1 500
        500 https://artifacts-infisical-core.infisical.com/deb stable/main amd64 Packages

Pin that version in your configuration management if consistency across a fleet matters to you. Then install:

sudo apt install -y infisical-core

On Rocky Linux, AlmaLinux and RHEL:

curl -1sLf 'https://artifacts-infisical-core.infisical.com/setup.rpm.sh' | sudo -E bash
sudo dnf install -y infisical-core
rpm -q infisical-core

The RPM carries an Amazon Linux release tag, which looks alarming and installs cleanly on Rocky 10 anyway:

infisical-core-0.162.19-1.amazon2023.x86_64

One detail worth knowing before you plan an upgrade to a brand new distribution release: this repository is not keyed to the distribution codename. The apt source it writes is a single stable main suite, so a fresh Ubuntu LTS gets the same build as the previous one on release day. That is unusual and it is good news, because most third-party repositories lag a new LTS by weeks.

Provide PostgreSQL and Redis

On Ubuntu, both come from the archive. This is also the moment to point at a managed database instead, if you have one:

sudo apt install -y postgresql postgresql-contrib redis-server
sudo systemctl enable --now postgresql redis-server

Rocky Linux 10 needs a different command, and the reason is worth stating plainly: there is no redis package in RHEL 10 or its rebuilds. The distribution ships Valkey, the Redis fork, in its place. Infisical connects to it over the same protocol and the same redis:// URL:

sudo dnf install -y postgresql-server valkey
sudo postgresql-setup --initdb
sudo systemctl enable --now postgresql valkey

One caveat before you copy this onto a production box: Infisical documents Redis 6.x and 7.x as the supported range, and both of these are version 8. Redis 8.0.5 on Ubuntu and Valkey 8.0.9 on Rocky ran the instance in this guide without complaint, but you are ahead of the support matrix. Valkey answers as a Redis 7 server, which is exactly why the swap is invisible:

valkey-cli info server | grep -E 'valkey_version|redis_version'

It reports both its own version and the Redis version it speaks:

redis_version:7.2.4
valkey_version:8.0.9

Create the database and its owner. Use a generated password, not one you will paste into a ticket later:

PG_PASS=$(openssl rand -hex 24)
sudo -u postgres psql -c "CREATE USER infisical WITH PASSWORD '${PG_PASS}';"
sudo -u postgres psql -c "CREATE DATABASE infisical OWNER infisical;"
echo "${PG_PASS}"

On the RHEL family only, PostgreSQL rejects password logins over the loopback interface until you say so. Open the client authentication file:

sudo vim /var/lib/pgsql/data/pg_hba.conf

Change the IPv4 local line from ident to password authentication:

host    all             all             127.0.0.1/32            scram-sha-256

Reload PostgreSQL so the change takes effect. A reload re-reads the file without dropping existing connections, so a restart is not needed here:

sudo systemctl reload postgresql

With both datastores answering, the service still has nothing to connect to. That comes next.

Write the configuration file

The package reads a Ruby-style configuration file rather than environment variables. Generate the two secrets first:

openssl rand -hex 16      # ENCRYPTION_KEY
openssl rand -base64 32   # AUTH_SECRET

Then create the configuration directory and open the file:

sudo mkdir -p /etc/infisical
sudo vim /etc/infisical/infisical.rb

Paste the five required settings, substituting the values you just generated:

infisical_core['ENCRYPTION_KEY'] = 'paste-the-hex-16-value-here'
infisical_core['AUTH_SECRET'] = 'paste-the-base64-32-value-here'
infisical_core['DB_CONNECTION_URI'] = 'postgres://infisical:[email protected]:5432/infisical'
infisical_core['REDIS_URL'] = 'redis://127.0.0.1:6379'
infisical_core['SITE_URL'] = 'https://secrets.example.com'
infisical_core['PORT'] = 8080

That file is the crown jewels of the instance, so lock it down before you go further:

sudo chmod 600 /etc/infisical/infisical.rb

Apply the configuration. The reconfigure step runs the migrations and starts the supervised service:

sudo infisical-ctl reconfigure

It finishes with a line naming the service it just rebuilt:

Infra Phase complete, 48/137 resources updated in 10 seconds
infisical-core Reconfigured!

Check the service and confirm the API answers. Note that infisical-ctl tail follows the log and does not exit, so use status for scripts:

sudo infisical-ctl status
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/api/status

A healthy install prints the supervised process and an HTTP 200:

run: infisical_core: (pid 5269) 43791s; run: log: (pid 5267) 43792s
200

The package binds to 127.0.0.1:8080 by default, which is the opposite of the Compose behaviour and the safer of the two. Nothing is exposed until you deliberately proxy it.

Step 4: Put Nginx and a Let’s Encrypt certificate in front

Both installation paths end with a plain HTTP service on loopback, and both need the same thing next. Install Nginx with the certbot plugin:

sudo apt install -y nginx certbot python3-certbot-nginx

Rocky, AlmaLinux and RHEL need two extra things first. The certbot packages live in EPEL, which is not enabled by default, and minimal cloud images ship without firewalld at all. Install all four together so neither surprises you. The firewalld configuration guide covers the zone model if you need it:

sudo dnf install -y epel-release
sudo dnf install -y nginx certbot python3-certbot-nginx firewalld
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload

Skipping the EPEL line is the usual failure here, and it announces itself clearly:

Error: Unable to find a match: certbot python3-certbot-nginx

The two families disagree about where virtual hosts live, and copying the Debian layout onto Rocky is the single most common way this step fails. Ubuntu and Debian use sites-available with a symlink; RHEL, Rocky and AlmaLinux have no such directory and read everything from conf.d instead.

On Ubuntu and Debian, create the file here. Static config files are not shell contexts, so the domain goes in as a placeholder and gets substituted afterwards:

sudo vim /etc/nginx/sites-available/infisical

The RHEL family reads its virtual hosts from a different directory, so use this path instead and skip the symlink step further down:

sudo vim /etc/nginx/conf.d/infisical.conf

Add the proxy block. The WebSocket upgrade headers and the forwarded protocol both matter, the first for live UI updates and the second so Infisical builds HTTPS URLs instead of HTTP ones:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

server {
    listen 80;
    server_name SITE_DOMAIN_HERE;

    client_max_body_size 20M;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 300s;
    }
}

Debian family hosts then substitute the hostname, enable the site, and drop the default vhost so it cannot answer for your domain:

sudo sed -i "s/SITE_DOMAIN_HERE/${SITE_DOMAIN}/g" /etc/nginx/sites-available/infisical
sudo ln -sf /etc/nginx/sites-available/infisical /etc/nginx/sites-enabled/infisical
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

On the RHEL family the substitution targets the other path, and the service needs starting because the RPM leaves it disabled rather than running it for you. The stock server block inside /etc/nginx/nginx.conf can stay: your vhost is matched by name, and that block only answers requests that carry no matching Host header, such as someone browsing the bare IP. Comment it out if you would rather they got nothing than the Nginx welcome page:

sudo sed -i "s/SITE_DOMAIN_HERE/${SITE_DOMAIN}/g" /etc/nginx/conf.d/infisical.conf
sudo nginx -t
sudo systemctl enable --now nginx

One more gate stands between a running Nginx and a working proxy on those systems.

SELinux blocks the proxy until you say otherwise

With SELinux enforcing, Nginx is confined and cannot open a connection to the backend, not even on loopback. The reverse proxy returns a 502 and the error log is unambiguous:

[crit] connect() to 127.0.0.1:8080 failed (13: Permission denied) while connecting to upstream, client: 127.0.0.1, server: secrets.example.com, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:8080/"

The fix is one boolean, and it survives reboots because of the -P flag. Never turn SELinux off to work around this:

sudo setsebool -P httpd_can_network_connect 1

The same request that returned 502 now returns 200, with no other change:

curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Host: ${SITE_DOMAIN}" http://127.0.0.1/

Now request the certificate. The HTTP-01 challenge needs port 80 reachable from the internet and an A record already resolving, and it works with any DNS provider:

sudo certbot --nginx -d "${SITE_DOMAIN}" --non-interactive --agree-tos --redirect -m "${ADMIN_EMAIL}"

Certbot rewrites the vhost for TLS, adds the redirect, and installs a renewal timer. On the RHEL family the path in that last line reads /etc/nginx/conf.d/infisical.conf instead:

Deploying certificate
Successfully deployed certificate for secrets.example.com to /etc/nginx/sites-enabled/infisical
Congratulations! You have successfully enabled HTTPS on https://secrets.example.com

Confirm the renewal path works before you forget about it, and check what the certificate actually says:

sudo certbot renew --dry-run
echo | openssl s_client -connect "${SITE_DOMAIN}:443" 2>/dev/null | openssl x509 -noout -issuer -dates

The issuer should read Let’s Encrypt and the expiry should sit about ninety days out. Here is the whole verification, plus the payoff further down this guide, from the test instance:

Terminal output verifying the Infisical HTTPS certificate and infisical run injecting three secrets

Only one thing keeps that step from working for everyone, and it has nothing to do with Infisical.

When port 80 cannot reach the internet

The HTTP-01 path above is the one that ran in this lab; the DNS-01 variant below is assembled from the plugin documentation rather than exercised on a second domain. Servers on a private LAN, behind NAT, or in a locked-down VPC cannot answer an HTTP-01 challenge. Use a DNS-01 challenge instead, which proves ownership through a TXT record and needs no inbound traffic at all. Certbot ships a plugin per provider:

DNS providerCertbot plugin package
Cloudflarepython3-certbot-dns-cloudflare
AWS Route 53python3-certbot-dns-route53
DigitalOceanpython3-certbot-dns-digitalocean
Google Cloud DNSpython3-certbot-dns-google
Linodepython3-certbot-dns-linode
Any RFC2136 server (BIND)python3-certbot-dns-rfc2136

Package names in that table are the Debian and Ubuntu ones. On the RHEL family they come from EPEL and not every plugin is packaged there, so check with dnf search certbot-dns before committing to a provider.

The worked example below uses Cloudflare. The API token needs the Zone DNS Edit permission on that zone, and the credentials file is created with restrictive permissions first so it is never briefly world readable. Substitute your provider’s plugin and file format if you are on something else:

sudo apt install -y python3-certbot-dns-cloudflare
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
echo "dns_cloudflare_api_token = your-api-token-here" | sudo tee /etc/letsencrypt/cloudflare.ini > /dev/null
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  -d "${SITE_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}"

The default propagation wait is ten seconds, which is optimistic for most DNS providers and a common cause of validation failures, hence the longer value above.

One more step that the HTTP-01 path did for you automatically: certonly obtains the certificate and stops there. Nothing has touched your Nginx configuration yet, so the site is still plain HTTP. Install the certificate into the vhost and add the redirect:

sudo certbot install --cert-name "${SITE_DOMAIN}" --nginx --redirect --non-interactive

Whichever path you took, the instance is now reachable over HTTPS and completely unclaimed. Fix that in the next two minutes.

Step 5: Create the admin account and close the door behind you

Browse to your domain and Infisical sends you to /admin/signup. The first account created becomes the instance super admin, which means whoever reaches this page first owns your secrets. Create it immediately after the service starts, not tomorrow.

Infisical self-hosted admin account creation page showing password policy checks

The password rules are enforced client side and one of them is unusual: alongside the length and character checks, the form reports whether the password appears in a known breach corpus. Nothing stops you choosing a weak password, but the instance will tell you it is a bad idea before you commit.

After the account comes the organization name, and then the setup wizard asks the question that matters most on a public instance: who may create accounts. Leave it on invite only unless you have a reason not to, and trim the login methods to the ones you actually use.

Infisical instance setup screen selecting who can create accounts and allowed authentication methods

Do not read the inviteOnlySignup field on the status endpoint as confirmation. It mirrors the internal allow-signup flag rather than describing the policy its name suggests, so it is a poor thing to alert on. Check the setting in the Server Console instead, and confirm it by opening the signup page in a private window.

Step 6: Create a project and store the first secret

Projects are the unit of isolation. Inside a project you get environments, which default to Development, Staging and Production, and folders for grouping secrets by service or path. Access is granted per project, so the shape of your projects becomes the shape of your blast radius later.

Create one from the Secrets Management product page, name it after the service that will consume it, then add secrets with the Add Secret panel. Values are masked in the list until you click Reveal Values, which is the correct default for a screen someone might share:

Infisical secrets dashboard for the billing-api project showing three masked secrets in the Development environment

Bulk import is available too. Dragging a .env, JSON or YAML file onto the panel loads every key at once, which is how most migrations start: import the file, verify the values, then delete the file from the server it came from. Compared to encrypting secrets inside Ansible, the win is that rotation happens in one place instead of in every repository that holds a copy.

Step 7: Give the application its own machine identity

Applications must never log in as a person. Infisical models non-human clients as machine identities, and the default authentication method for them is Universal Auth: a client ID and a client secret that exchange for a short-lived access token. The identity is granted a role in specific projects, so it reads only what you attached it to.

Sequence diagram of universal auth login, access token, and secret injection into an application process

Create one under Access Control, Machine Identities. Give it the name of the workload rather than the person who made it, pick the Member organization role, then add it to the project with the Viewer role if it only needs to read. The project roles available are Admin, Member, Viewer and No Access.

Open the Universal Auth method on the identity to find the client ID and to generate a client secret. Read the token settings on that panel carefully, because the defaults are generous:

Infisical machine identity Universal Auth panel showing client ID, token TTL and a newly generated client secret

The access token TTL and max TTL both default to 2592000 seconds, which is thirty days. A token that lives for a month is barely shorter lived than the credential it was supposed to protect. Edit the method and set the TTL to the length of a deploy, measured in minutes, and the max TTL to something an hour or less. The trusted IP fields default to 0.0.0.0/0 plus ::/0, so narrowing only the IPv4 entry leaves the door open on IPv6, and both can be limited to your CI egress range. Lockout is enabled by default at three failed attempts with a five minute lockout.

The client secret is shown exactly once. Store it where the workload can read it and nowhere else.

Step 8: Inject secrets into a real service

Install the CLI on the machine that runs the workload. It is a separate package from the server, published in its own repository:

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

Rocky, AlmaLinux and RHEL take the RPM setup script and dnf:

curl -1sLf 'https://artifacts-cli.infisical.com/setup.rpm.sh' | sudo -E bash
sudo dnf install -y infisical

Point the CLI at your own instance and exchange the identity credentials for a token. Use INFISICAL_DOMAIN, which takes precedence over the older INFISICAL_API_URL when both are set. The /api suffix is optional either way because the CLI appends it when it is missing:

export INFISICAL_DOMAIN="https://${SITE_DOMAIN}"
export INFISICAL_TOKEN=$(infisical login --method=universal-auth \
  --client-id="your-client-id" \
  --client-secret="your-client-secret" \
  --plain --silent)

Read the secrets the identity is allowed to see. The project ID comes from the project URL or its settings page:

infisical secrets --projectId="your-project-id" --env=dev

The CLI prints a table of everything in scope, and nothing that is out of scope:

┌─────────────────â”Ŧ──────────────────────────────────────────────────â”Ŧ─────────────┐
│ SECRET NAME     │ SECRET VALUE                                     │ SECRET TYPE │
├─────────────────â”ŧ──────────────────────────────────────────────────â”ŧ─────────────┤
│ DATABASE_URL    │ postgres://billing:[email protected]:5432/billing │ shared      │
│ JWT_SIGNING_KEY │ dGhpcy1pcy1hLWxhYi1zaWduaW5nLWtleQ==             │ shared      │
│ STRIPE_API_KEY  │ sk_test_51LabExampleKeyNotReal0000               │ shared      │
└─────────────────┴──────────────────────────────────────────────────┴─────────────┘

Now run the application through infisical run, which fetches the secrets and hands them to the child process as environment variables:

infisical run --projectId="your-project-id" --env=dev -- python3 app.py

The same application, launched without the wrapper, finds nothing, and there is no .env on the disk either way:

INF Injecting 3 Infisical secrets into your application process
app reading its config
  DATABASE_URL = postgres://billing:[email protected]:54...
  STRIPE_API_KEY set: True
  .env on disk: False

That is the happy path, and it is where most write-ups stop. The next part is why they should not.

Scoping the identity does not scope the process

Here is the part the quickstarts skip. A wrapper that sources the credentials file and then calls the CLI leaves the client ID and client secret in the environment of the application it launches. Anything that can run a shell inside your app can read them, mint fresh tokens forever, and your carefully shortened TTL means nothing.

Checking the child process environment shows exactly what leaks:

infisical run --projectId="$PROJECT_ID" --env=dev -- \
  bash -c 'printenv | grep -oE "^INFISICAL_[A-Z_]+" | sort'

Three of those four lines are credentials, not configuration:

INFISICAL_DOMAIN
INFISICAL_TOKEN
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET

The fix costs one command. Strip the credential variables at exec time so the application inherits values and never the credential that fetched them:

infisical run --projectId="$PROJECT_ID" --env=dev -- \
  env -u INFISICAL_TOKEN -u INFISICAL_UNIVERSAL_AUTH_CLIENT_ID -u INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET \
  bash -c 'printenv | grep -oE "^INFISICAL_[A-Z_]+" | sort'

The same check now returns one harmless line, the endpoint address:

INFISICAL_DOMAIN

Doing that by hand every time is how it gets forgotten, so bake it into the service definition.

The systemd unit that puts it together

Setting INFISICAL_UNIVERSAL_AUTH_CLIENT_ID and INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET is not enough on its own, which is the first thing most people try. The CLI ignores them for run and drops into an interactive login that fails under systemd. A tiny wrapper handles the exchange. Create it:

sudo vim /usr/local/bin/infisical-exec

It logs in, replaces itself with the CLI, and strips the credentials from whatever runs next:

#!/bin/bash
set -euo pipefail

token=$(infisical login --method=universal-auth \
  --client-id="${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID}" \
  --client-secret="${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET}" \
  --plain --silent)

exec env INFISICAL_TOKEN="${token}" \
  infisical run --projectId="${INFISICAL_PROJECT_ID}" --env="${INFISICAL_ENV}" --silent -- \
  env -u INFISICAL_TOKEN -u INFISICAL_UNIVERSAL_AUTH_CLIENT_ID -u INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET "$@"

Make it executable, then create the credentials file that systemd will read:

sudo chmod 755 /usr/local/bin/infisical-exec
sudo mkdir -p /etc/billing-api
sudo vim /etc/billing-api/infisical.env

Six lines, and only two of them are secret. The update check is disabled so the CLI does not reach out to GitHub every time the unit restarts:

INFISICAL_DOMAIN=https://secrets.example.com
INFISICAL_PROJECT_ID=your-project-id
INFISICAL_ENV=prod
INFISICAL_DISABLE_UPDATE_CHECK=true
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=your-client-id
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=your-client-secret

Restrict it to the service account that needs it, so a compromised web process cannot read the identity:

sudo chown root:billing /etc/billing-api/infisical.env
sudo chmod 640 /etc/billing-api/infisical.env

Now the unit itself. Further hardening options for this pattern are covered in the guide to running containers as systemd services:

sudo vim /etc/systemd/system/billing-api.service

The credentials arrive through EnvironmentFile and never appear in the unit or in ps output:

[Unit]
Description=billing-api with secrets injected by Infisical
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=billing
EnvironmentFile=/etc/billing-api/infisical.env
ExecStart=/usr/local/bin/infisical-exec /usr/local/bin/billing-api
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes

[Install]
WantedBy=multi-user.target

Start it and read the journal:

sudo systemctl daemon-reload
sudo systemctl enable --now billing-api
sudo journalctl -u billing-api -n 20 --no-pager

The service comes up with its configuration already in memory:

systemd[1]: Started billing-api.service - billing-api with secrets injected by Infisical.
infisical-exec[10865]: INF Injecting 3 Infisical secrets into your application process
infisical-exec[10881]: billing-api starting
infisical-exec[10881]:   DATABASE_URL present: yes
infisical-exec[10881]:   STRIPE_API_KEY present: yes

Verify the hardening held by reading the running process environment directly, which is the only check that actually proves it. The unit’s main PID is the CLI wrapper; the application is its child:

MAIN=$(systemctl show -p MainPID --value billing-api)
APP=$(pgrep -P ${MAIN} | head -1)
for P in ${MAIN} ${APP}; do
  echo "pid ${P} ($(ps -o comm= -p ${P}))"
  echo "  credentials: $(sudo cat /proc/${P}/environ | tr '\0' '\n' | grep -cE '^INFISICAL_(TOKEN|UNIVERSAL_AUTH_CLIENT_ID|UNIVERSAL_AUTH_CLIENT_SECRET)=')"
  echo "  secrets:     $(sudo cat /proc/${P}/environ | tr '\0' '\n' | grep -cE '^(DATABASE_URL|STRIPE_API_KEY|JWT_SIGNING_KEY)=')"
done

The split is the whole point. The wrapper holds the credentials and no secrets; the application holds the secrets and no credentials. The workload in this lab was a shell stub that ends in sleep, which is why the child process reports that name:

pid 11720 (infisical)
  credentials: 3
  secrets:     0
pid 11737 (sleep)
  credentials: 0
  secrets:     3

Rotation now works the way it was supposed to. Change a value in the UI, restart the service, and the new value is live without a deploy, a rebuilt image, or a config management run. Kubernetes workloads get the same behaviour through the operator rather than the CLI, and the External Secrets Operator is the vendor-neutral way to wire it if you already run that.

Troubleshooting the errors you will actually hit

Attention: SMTP has not been configured for this instance

This yellow banner appears on every page of a fresh install and the backend logs a matching connect ECONNREFUSED 127.0.0.1:587 at startup. Nothing is broken. Without SMTP the instance cannot send invitations or password resets, so you either configure the SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD and SMTP_FROM_ADDRESS settings, or you accept that every account has to be created by the admin and every password reset is a support ticket.

error: unable to parse email and password for authentication

The full message ends with Failed to automatically trigger login flow. Please run [infisical login] manually to login. and it means the CLI found no usable token. Exporting the Universal Auth client ID and secret alone does not authenticate infisical run; the credentials have to be exchanged for a token first, which is exactly what the wrapper script above does. Under systemd this failure loops the unit through Restart=on-failure.

Folder with path ‘/’ in environment ‘nosuchenv’ was not found

The complete error includes the request URL and [status-code=404]. The environment slug is wrong. Slugs are not the display names: the environment shown as Development has the slug dev. Check the project settings page for the exact slugs before wiring them into a unit file. This failure exits 1, so a supervisor will notice it.

Injecting 0 Infisical secrets into your application process

This one is dangerous precisely because it is not an error. When an identity has no access to the path, or the environment is simply empty, the CLI prints that line and exits 0, and your application starts with no configuration and improvises from there. A denied path and an empty path look identical from the outside.

Refuse to start when the count is zero. Two lines in the wrapper turn a silent misconfiguration into a loud one:

count=$(infisical secrets --projectId="${INFISICAL_PROJECT_ID}" --env="${INFISICAL_ENV}" --plain --silent | grep -c .)
[ "${count}" -eq 0 ] && { echo "no secrets readable, refusing to start" >&2; exit 78; }

One more behaviour to know about before you wire alerts to this.

Exit codes from the wrapped process are flattened

Worth knowing before you build alerting on it: infisical run does not propagate the child’s exit status. Children exiting 3, 7 and 42 in testing all surfaced as exit 1 from the wrapper. Any supervisor logic that branches on specific exit codes needs to read them from the application’s own logging instead.

What the free self-hosted tier leaves out

Self-hosting Infisical is free and the free tier covers the whole workflow above: unlimited secrets, projects, environments, machine identities, the CLI, the SDKs and the Kubernetes operator. Several things are gated behind a licence key, and one of them catches people out.

Audit logs are a paid feature. The nav item is visible on a free instance, and clicking it returns a plain answer:

Your current plan does not include access to audit logs. To unlock this feature, please upgrade to Infisical Pro plan.

If your reason for self-hosting is a compliance requirement to prove who read which secret and when, budget for the licence or plan to ship the evidence some other way. Secret rotation, point-in-time recovery, IP allowlisting, SAML SSO, custom roles and dynamic secrets sit on the same paid side of the line. Secret versioning, usefully, does not: an unlicensed instance keeps version history even though it cannot roll back to a point in time. None of that blocks the setup in this guide, but all of it changes the answer to “is this a Vault replacement for us”. For comparison, HashiCorp Vault gives away dynamic secrets in its open source build and asks you to work much harder for the developer experience, while Google Cloud Secret Manager removes the operational burden entirely and bills per access.

Harden this instance before it faces the internet

Run through these before you point production at the box. Each one took a real mistake to learn.

  1. Regenerate the sample keys. Confirm ENCRYPTION_KEY and AUTH_SECRET no longer match the published example values, and back the encryption key up somewhere offline. Losing it makes every stored secret unrecoverable.
  2. Check what is listening. Run sudo ss -tlnp | grep 8080 and expect 127.0.0.1. Docker published ports bypass UFW rules, so verify rather than assume.
  3. Confirm the redirect and the certificate. curl -sI "http://${SITE_DOMAIN}/" must return a 301, and certbot renew --dry-run must pass.
  4. Keep signup invite only and remove any login method you do not use.
  5. Cut the token TTL. Thirty days is the default for a machine identity access token. Set it to the length of a job, not the length of a month.
  6. Narrow the trusted IPs on each identity from 0.0.0.0/0 and ::/0 to the egress range that will actually use it. Leaving the IPv6 entry wide open undoes the IPv4 one.
  7. Give every workload its own identity with the Viewer role and project access limited to what it reads. Shared identities cannot be revoked without an outage.
  8. Strip the credentials at exec. Verify with /proc/PID/environ that the application process holds zero INFISICAL_TOKEN or client secret variables.
  9. Fail closed on zero secrets so a revoked identity stops the service instead of silently starting it unconfigured.
  10. Back up PostgreSQL on a schedule and test a restore. The database holds every secret; the encryption key alone will not bring them back.

Work through that list once and the instance stops being a lab toy. The commands above were run end to end on both installation paths, so the only variable left is your own hostname.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Security UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Setup WireGuard VPN on Ubuntu 24.04 / Debian 13 / Rocky Linux 10 Debian Setup WireGuard VPN on Ubuntu 24.04 / Debian 13 / Rocky Linux 10 Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers Best CompTIA CySA+ Books for CS0-004 and CS0-003 Books Best CompTIA CySA+ Books for CS0-004 and CS0-003 Best DevSecOps Books for 2026 Books Best DevSecOps Books for 2026 How To Install Docker Desktop on Ubuntu 22.04|20.04|18.04 Containers How To Install Docker Desktop on Ubuntu 22.04|20.04|18.04

Leave a Comment

Press ESC to close