DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

Self-Hosting Infisical on Coolify: A Production-Style Guide with Compose, TLS, Machine Identities and GitHub OIDC

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.com is 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 A record, e.g. secrets.example.com, pointing at that server before you deploy, so Let's Encrypt can issue the certificate.
  • openssl locally 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:
Enter fullscreen mode Exit fullscreen mode

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 noeviction on Redis matters. Infisical uses Redis for job queues; an eviction policy that drops keys under memory pressure can silently lose queued work. noeviction makes Redis return errors instead, which you can see and alert on. --save 300 10 snapshots 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_on with service_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-alpine to 16 on an existing volume; a major upgrade needs a dump/restore.
  • Telemetry: TELEMETRY_ENABLED=false turns 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 as ENCRYPTION_KEY. The documented generation is still openssl rand -hex 16; if you hit a key-format error at boot, switch to that.

3. Create the resource in Coolify

  1. Projects → New project (e.g. platform), pick the production environment, + New resource.
  2. 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.)
  3. Open the service's Domains and set it to https://secrets.example.com. With the SERVICE_URL_BACKEND_8080 line, Coolify already knows the container port; in a manual setup, add :8080 to the domain so the proxy routes to the right port. Leave db and redis without a domain.
  4. Make sure the server's proxy (Traefik) is running with the Let's Encrypt resolver. If you changed proxy settings, restart the proxy.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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

  1. Open the URL and sign up. The first account becomes the instance admin, so do this before announcing the URL to anybody.
  2. Open the Server Console (top-right) and disable open sign-ups. Otherwise anyone who finds a public instance can register.
  3. Enable MFA on the admin account.
  4. (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:

  1. Organization → Access Control → Machine Identities → Create Organization Machine Identity.
  2. 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.
  3. 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.
  4. 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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two things bite people:

  1. Never use * or repo:* 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.
  2. GitHub changed the default sub format 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
Enter fullscreen mode Exit fullscreen mode

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_KEY backed 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/status and container restarts

Top comments (0)