Infisical is an open-source secrets manager. Coolify is a self-hosted PaaS that runs Docker workloads behind a Traefik reverse proxy with automatic TLS. This guide puts them together: a production-style Infisical on Coolify, with the real Compose file I ran, the Coolify-specific tricks that make it painless, the machine-identity options (and which one to pick), and the failure modes I hit.
Conventions. Every hostname, ID and secret below is a placeholder.
secrets.example.comis your domain. Nothing here came from a real instance. Menu names reflect the Infisical and Coolify versions I used; both UIs change often, so verify against yours.
What you'll build
flowchart LR
C["Client / CI"] -- HTTPS --> T["Traefik (Coolify proxy, Let's Encrypt)"]
T --> I["infisical backend :8080 (API + UI)"]
I --> P[("PostgreSQL: encrypted secrets, state")]
I --> R[("Redis: queues, cache, rate limiting")]
Three containers on one Docker network. Only the Infisical container is routed through the proxy; Postgres and Redis publish no ports.
Prerequisites
- A Coolify instance with at least one connected server. 2 GB of RAM is a comfortable floor for Infisical + Postgres + Redis. (I'll show a lean profile below that runs in much less, with limits you can tune.)
- A DNS
Arecord, e.g.secrets.example.com, pointing at that server before you deploy, so Let's Encrypt can issue the certificate. -
openssllocally if you generate secrets yourself. - A password manager or offline vault for one particular key (next section).
1. Understand the three secrets (and the one you must not lose)
| Variable | What it does | If you lose or change it |
|---|---|---|
ENCRYPTION_KEY |
Encrypts the instance's root key material. The documented way to generate it is openssl rand -hex 16 (32 hex characters) |
Every secret in the database becomes unreadable. A database backup alone doesn't help |
AUTH_SECRET |
Signs JWTs / sessions (openssl rand -base64 32) |
Sessions break; users must sign in again |
POSTGRES_PASSWORD |
Database password (openssl rand -hex 24) |
You can't connect to the database |
Back up ENCRYPTION_KEY outside the server on day one. Also remember: anything printed in a chat, a ticket or a shared terminal should be treated as exposed. If it happens to a signing key or an encryption key, rotate it.
2. The Compose file
Two ways to provide those values: type them into Coolify's Environment Variables tab, or let Coolify generate them with magic variables. I'll show the second, because it removes copy-paste of secrets entirely, then the manual variant.
Option A: Coolify magic variables (what I deployed)
Coolify generates values for env names that follow a pattern (documented in its Docker Compose knowledge base):
| Pattern | Generates |
|---|---|
SERVICE_URL_<ID> / SERVICE_FQDN_<ID>
|
The service URL (with scheme) / the domain only |
SERVICE_USER_<ID> |
Random 16-character alphanumeric username |
SERVICE_PASSWORD_<ID> |
32-character password, no symbols (..._64_<ID> for 64) |
SERVICE_REALBASE64_<ID> / _64_ / _128_
|
Base64 of 32 / 64 / 128 random bytes |
SERVICE_HEX_32_<ID> |
32-character hex string |
Reusing the same full name gives every service the same stored value. A numeric suffix on SERVICE_URL_* (for example SERVICE_URL_BACKEND_8080) tells Coolify which container port to route the generated domain to.
services:
backend:
image: 'infisical/infisical:v0.154.6' # pin a release; see "Upgrades"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
- SERVICE_URL_BACKEND_8080 # Coolify: generate a domain and route to port 8080
- NODE_ENV=production
- 'SITE_URL=${SITE_URL:-${SERVICE_URL_BACKEND}}'
- 'ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTIONKEY}'
- 'AUTH_SECRET=${SERVICE_REALBASE64_64_AUTHSECRET}'
- 'DB_CONNECTION_URI=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@db:5432/${POSTGRES_DB:-infisical}'
- 'REDIS_URL=redis://redis:6379'
- TELEMETRY_ENABLED=false
- NODE_OPTIONS=--max-old-space-size=1024
mem_limit: 1536m
restart: unless-stopped
redis:
image: 'redis:7'
command: redis-server --maxmemory 64mb --maxmemory-policy noeviction --save 300 10
volumes:
- 'redis-data:/data'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 10
mem_limit: 96m
restart: unless-stopped
db:
image: 'postgres:14-alpine'
command: postgres -c shared_buffers=64MB -c max_connections=30 -c work_mem=4MB -c effective_cache_size=256MB -c maintenance_work_mem=32MB
environment:
- 'POSTGRES_USER=${SERVICE_USER_POSTGRES}'
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
- 'POSTGRES_DB=${POSTGRES_DB:-infisical}'
volumes:
- 'pg-data:/var/lib/postgresql/data'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
mem_limit: 256m
restart: unless-stopped
volumes:
redis-data:
pg-data:
Details worth understanding rather than copying:
-
$${POSTGRES_USER}(double dollar) in the Postgres health check makes Compose pass a literal${POSTGRES_USER}for the container's shell to expand, instead of interpolating it at parse time. -
--maxmemory-policy noevictionon Redis matters. Infisical uses Redis for job queues; an eviction policy that drops keys under memory pressure can silently lose queued work.noevictionmakes Redis return errors instead, which you can see and alert on.--save 300 10snapshots to disk so queues survive a restart. -
The memory limits and Postgres tuning are a *lean profile* for small VPSes: ~1.5 GB for the app (with Node's heap capped at 1 GB), ~96 MB for Redis and ~256 MB for Postgres with
max_connections=30. Raise them as your usage grows. If you run out of memory, you want a container restart you can see in the logs, not the host's OOM killer picking a victim. -
Keep
depends_onwithservice_healthy: Infisical runs database migrations at startup, so starting it before Postgres is ready produces confusing first-boot errors. -
Postgres major version: don't bump
postgres:14-alpineto 16 on an existing volume; a major upgrade needs a dump/restore. -
Telemetry:
TELEMETRY_ENABLED=falseturns off usage telemetry.
Option B: manual variables
If you don't want magic variables (or you're not on Coolify), set these as secrets in the Environment Variables tab and replace the ${SERVICE_*} references:
| Variable | Value |
|---|---|
ENCRYPTION_KEY |
openssl rand -hex 16 |
AUTH_SECRET |
openssl rand -base64 32 |
POSTGRES_PASSWORD |
openssl rand -hex 24 |
SITE_URL |
https://secrets.example.com (no trailing slash) |
Mark them as secrets so Coolify hides them in the UI and in logs.
One observation from my own deployment: Coolify's
SERVICE_PASSWORD_*(32 alphanumeric characters) was accepted by Infisical asENCRYPTION_KEY. The documented generation is stillopenssl rand -hex 16; if you hit a key-format error at boot, switch to that.
3. Create the resource in Coolify
-
Projects → New project (e.g.
platform), pick theproductionenvironment, + New resource. - Choose Docker Compose Empty and paste the file. (If your Coolify ships an Infisical one-click template, you can use it: the variables are the same.)
- Open the service's Domains and set it to
https://secrets.example.com. With theSERVICE_URL_BACKEND_8080line, Coolify already knows the container port; in a manual setup, add:8080to the domain so the proxy routes to the right port. Leavedbandrediswithout a domain. - Make sure the server's proxy (Traefik) is running with the Let's Encrypt resolver. If you changed proxy settings, restart the proxy.
- Deploy.
SITE_URL must equal the public HTTPS URL users hit. If you change the domain later, change SITE_URL too.
4. Verify
curl -s https://secrets.example.com/api/status
A healthy instance answers with a small JSON document ("message": "Ok", plus flags such as whether email is configured). If you get a 502/504:
| Check | Why |
|---|---|
| Domain routes to port 8080 | The most common mistake |
db is healthy |
The backend waits for it, and fails to migrate otherwise |
| Backend logs show migrations finishing | First boot runs many of them; give it a minute |
| The proxy is up and the certificate was issued | Look at the Traefik/proxy logs for ACME errors; DNS must already resolve |
I didn't add a container health check to the backend. If you want one, test that wget or curl exists in the image before relying on it, or monitor /api/status from outside.
5. First login, then lock the door
- Open the URL and sign up. The first account becomes the instance admin, so do this before announcing the URL to anybody.
- Open the Server Console (top-right) and disable open sign-ups. Otherwise anyone who finds a public instance can register.
- Enable MFA on the admin account.
- (Optional) Configure SMTP (
SMTP_HOST,SMTP_PORT,SMTP_USERNAME,SMTP_PASSWORD,SMTP_FROM_ADDRESS, plus the TLS options your provider needs) for invites and password resets. Until you do, the UI shows an SMTP has not been configured for this instance banner and email flows don't work.
6. Projects and environments
Create a project (e.g. my-app). Infisical creates dev, staging and prod environments by default. Use the slug shown in the project settings (not the display name) in CI and CLI configurations; they're different things and mixing them up gives confusing "not found" errors.
7. Machine identities: don't use a human account for automation
A machine identity is the non-human principal for apps and CI. The flow in the UI:
- Organization → Access Control → Machine Identities → Create Organization Machine Identity.
- Give it a name and an organization role. Admin and Member are broad; No Access is the right choice for a CI identity that only needs to read secrets from one project.
- Open it, and under Authentication add an auth method. The menu offers: Token Auth, Universal Auth, Kubernetes, GCP, Alibaba Cloud, AWS, Azure, OCI, OIDC, LDAP, TLS Certificate and JWT. Note: a newly created identity comes with Universal Auth already configured. If you plan to use something else, remove Universal Auth so there's only one way in.
- Add the identity to the project (identity page → Projects → +) with the narrowest role that works. Project roles include Admin, Developer, Viewer and No Access; for "read the prod secrets", Viewer is enough.
Option 1: Universal Auth (client ID and client secret)
The simple one. You get a client ID and client secret. A few defaults are worth changing:
| Setting | Default | Recommendation |
|---|---|---|
| Access token TTL / Max TTL | 2,592,000 s (30 days) | Hours or less for CI |
| Access token max uses | 0 (unlimited) | Set a bound if the job reads a known number of times |
| Client secret TTL | 0 (never expires) | Set a TTL, and enable the expiry alert so your team is warned |
| Client secret max uses | 0 (unlimited) | Low, if the secret is for a single pipeline |
| Trusted IPs | 0.0.0.0/0 | Restrict to your CI/server ranges; per Infisical's docs this restriction is a paid feature |
| Lockout | On (threshold 3) | Leave it on |
The documentation itself shows two different default TTLs for the access token (30 days in the configuration section, 7200 s in a login example), so check the effective values on your own identity.
Use it from the CLI:
# domain flag for self-hosted instances (older CLI versions may expect the API URL with /api)
export INFISICAL_TOKEN=$(infisical login \
--method=universal-auth \
--client-id="$INFISICAL_CLIENT_ID" \
--client-secret="$INFISICAL_CLIENT_SECRET" \
--domain=https://secrets.example.com \
--silent --plain)
# inject secrets as environment variables of one process; nothing is written to disk
infisical run --projectId="<project-id>" --env=prod --domain=https://secrets.example.com -- ./start-service
(Confirm flags with infisical login --help for your CLI version.) And from GitHub Actions. Note the method value for the action is universal, not universal-auth (that spelling is the CLI's):
- uses: Infisical/[email protected] # pin the version or, better, a commit SHA
with:
method: universal
client-id: ${{ secrets.INFISICAL_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
domain: https://secrets.example.com
project-slug: my-app
env-slug: prod
Only the client ID and client secret live in GitHub; the rest is fetched at runtime and exported as environment variables. Rotating a secret in Infisical needs no repo change.
Option 2 (recommended for GitHub Actions): OIDC, no stored secret
Universal Auth still leaves a long-lived client secret in GitHub. With OIDC Auth, the workflow presents a short-lived token that GitHub signs, and Infisical checks its claims. Nothing to rotate, nothing to leak.
In the identity, add OIDC Auth:
| Field | Value |
|---|---|
| OIDC Discovery URL | https://token.actions.githubusercontent.com |
| Issuer | https://token.actions.githubusercontent.com |
| Subject | pin the owner (and ideally the branch): repo:<OWNER>/*:ref:refs/heads/main (see the note below on GitHub's newer subject format) |
| Audiences | the action's default is https://github.com/<OWNER>; whatever you set must match exactly what the workflow requests |
permissions:
id-token: write # required to request the OIDC token
contents: read
steps:
- uses: Infisical/[email protected]
with:
method: oidc
identity-id: ${{ vars.INFISICAL_IDENTITY_ID }} # an identifier, not a secret
domain: ${{ vars.INFISICAL_URL }}
project-slug: my-app
env-slug: prod
Two things bite people:
-
Never use
*orrepo:*as the Subject. The token issuer is shared by all of GitHub, so the Subject is the only thing separating your repos from everyone else's. Wildcards in Infisical's Subject also match:and/, so always put a/or@right after the owner. -
GitHub changed the default
subformat for new, renamed and transferred repositories (and for organizations that opt in) to include immutable IDs:repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:ref:.... A policy written for the old name-only format stops matching. Print the real claims from a test job and compare them character by character before debugging anything else.
I covered the threat model in depth in Stop Storing Secrets in GitHub Actions. A side note for private repositories on GitHub's Free plan: organization variables are only available to public repositories, so put INFISICAL_IDENTITY_ID and INFISICAL_URL in repository variables.
8. Operations
Backups. Two independent things, stored separately:
# logical backup of the database (adjust the container name; run on the Docker host)
docker exec <db-container> pg_dump -U <user> -d infisical -Fc > infisical-$(date +%F).dump
Plus ENCRYPTION_KEY in an offline vault. If your Postgres is a Coolify-managed database resource, you can use Coolify's scheduled backups to S3-compatible storage instead. Either way, test a restore into a scratch instance with the same ENCRYPTION_KEY.
Upgrades. Pin the image tag, read the release notes, take a database snapshot, bump the tag, redeploy, and watch the migration logs. Never ride latest in production: Infisical migrates the schema on boot, so an unplanned image pull can change your database.
Rotation. Schedule rotation of client secrets (or move to OIDC and stop having them). Revoke any Coolify API tokens you created during setup once you're done with them.
Audit. The audit logs show which identity read which secret. Review them; a log nobody reads is decoration.
Network exposure. Serve TLS only. If the instance is for internal use, restrict the proxy to a VPN or a tunnel instead of the open internet, and keep sign-ups disabled either way.
Monitoring. Alert on /api/status, on container restarts, on Redis errors (remember noeviction), and on disk usage of the Postgres volume.
Common pitfalls
| Symptom | Likely cause |
|---|---|
| 502 right after deploy | Wrong container port in the domain (needs 8080), or Postgres still starting |
| Certificate not issued | DNS not pointing at the server yet, or the proxy isn't running/was not restarted after changes |
Boot error about ENCRYPTION_KEY
|
Wrong length or format; regenerate with openssl rand -hex 16 (on a fresh database only) |
| Everyone signed out after a redeploy |
AUTH_SECRET changed between deploys (with magic variables, don't recreate the variable) |
| Secrets unreadable after a restore | Restored the database with a different ENCRYPTION_KEY
|
| No invite or reset emails | SMTP isn't configured |
| CI says the project isn't found | You used the project name instead of its slug |
403 on OIDC login |
Subject/audience mismatch, or the identity wasn't added to the project |
| Backend killed repeatedly | Memory limit too low for your usage; raise mem_limit and the Node heap together |
A note on automating this with AI agents
If you let an agent drive the setup, treat it like any other automation account: give it a scoped identity, never paste keystores or passwords into the conversation, pass secrets through files or stdin rather than printing them, and rotate anything that did end up in a transcript. The convenience is real, and so is the blast radius.
Checklist
- [ ] DNS resolves, proxy running, certificate issued
- [ ] Image tags pinned; Postgres major version consistent with the volume
- [ ]
ENCRYPTION_KEYbacked up offline; database backup tested - [ ] First admin created, open sign-ups disabled, MFA on
- [ ] SMTP configured (if you need invites)
- [ ] CI uses OIDC (or Universal Auth with short TTLs and limited uses)
- [ ] Identities have the narrowest role, one auth method each
- [ ] Alerts on
/api/statusand container restarts
Top comments (0)