Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

iac-coolify

Declarative Infrastructure as Code for Coolify — YAML in, plan/apply/destroy out. No HCL, no state file, no magic.

iac-coolify is a single-binary CLI that manages Coolify v4 resources declaratively, the way Terraform manages cloud resources — but native to Coolify, with Kubernetes-style YAML (apiVersion/kind/metadata/spec), a stateless-first model, and auto-generated docs.

Status: beta — Coolify v4.x only. APIs may change before v1.0.0.

Supported platforms

OSamd64arm64
Linux
macOS
Windows

How this documentation is organised

This documentation follows the Diátaxis framework:

  • Tutorials — a hands-on getting-started lesson.
  • How-to guides — recipes for specific tasks: installing and verifying releases, selecting environments, managing databases, moving resources between servers, interpolation, secrets, importing an existing instance, exploring interactively, and running in CI.
  • Reference — the generated resource schemas (Project, Environment, Application, Service, Database, EnvVar).
  • Explanation — the why behind the tool and its configuration model.

Getting started

This tutorial takes you from an empty shell to a previewed change against a Coolify instance, using the bundled examples/minimal/ configuration.

1. Install

On macOS or Linux, install through Homebrew (this repository is also the tap):

brew tap rems08/iac-coolify https://github.com/Rems08/infrastructure-as-coolify
brew trust rems08/iac-coolify    # recent Homebrew requires trusting third-party taps
brew install --cask iac-coolify

Alternatively, download a binary from the releases page, or pull the container image:

docker pull ghcr.io/rems08/infrastructure-as-coolify:latest
docker run --rm ghcr.io/rems08/infrastructure-as-coolify version

Every option — including go install and signature verification — is covered in Install.

2. Validate a configuration

Validation is fully offline — it needs no Coolify connection:

iac-coolify validate examples/minimal/

You should see Validated 1 application: web (no issues).

3. Preview the changes

plan resolves Coolify UUIDs from your logical metadata names (never written to YAML), fetches live state, and prints a per-field diff. Without a configured Coolify URL/token it runs offline, treating every resource as new:

iac-coolify plan examples/minimal/

# Against a live instance:
export COOLIFY_API_TOKEN=...                  # plus --coolify-url or COOLIFY_API_URL
iac-coolify plan examples/minimal/ --output=json --detailed-exitcode

--detailed-exitcode returns 0 (no changes), 2 (changes pending), or 1 (error) — handy in CI.

4. Apply and tear down

# Create resources in dependency order (project -> environment -> application)
iac-coolify apply examples/full-project/ --dry-run        # offline preview, mutates nothing
iac-coolify apply examples/full-project/ --auto-approve   # required in CI / non-interactive

# Remove them in reverse dependency order
iac-coolify destroy examples/full-project/ --auto-approve

A repeated apply on an unchanged state is a no-op, and a repeated destroy is too (a 404 on delete is treated as success).

Next steps

Install

iac-coolify ships as a single static binary for Linux, macOS and Windows (amd64 and arm64; Windows is amd64 only), plus a multi-arch container image.

Homebrew (macOS and Linux)

This repository doubles as a Homebrew tap — the cask under Casks/ is updated automatically on every stable release:

brew tap rems08/iac-coolify https://github.com/Rems08/infrastructure-as-coolify
brew trust rems08/iac-coolify    # recent Homebrew requires trusting third-party taps
brew install --cask iac-coolify
iac-coolify version

Upgrade later with:

brew upgrade --cask iac-coolify

The explicit tap URL is required because the repository is not named homebrew-*. On macOS the cask’s post-install step clears Gatekeeper’s quarantine attribute, so the binary runs without a “cannot be opened” prompt (releases are cosign-signed, not Apple-notarized — see Verify release signatures).

Binary download

Download the archive for your platform from the releases page, extract it, and put iac-coolify on your PATH:

tar -xzf iac-coolify_<version>_linux_amd64.tar.gz
install -m 0755 iac-coolify /usr/local/bin/iac-coolify

On macOS a hand-downloaded binary is quarantined by Gatekeeper; clear it with xattr -d com.apple.quarantine ./iac-coolify (the Homebrew cask does this for you).

Container image

docker pull ghcr.io/rems08/infrastructure-as-coolify:latest
docker run --rm ghcr.io/rems08/infrastructure-as-coolify version

Images are multi-arch (linux/amd64, linux/arm64). Replace :latest with a version such as :0.1.6 to pin a release.

go install

go install github.com/Rems08/infrastructure-as-coolify/cmd/iac-coolify@latest

A go install build has no release metadata, so iac-coolify version reports dev. Prefer the cask or a release binary when the version matters (support requests, reproducibility).

Verifying what you downloaded

Every release archive and container image is signed with cosign and ships SLSA build provenance — see Verify release signatures.

Verify release signatures

Every release is signed with cosign keyless (no long-lived key — the signature is bound to this repository’s GitHub Actions identity) and ships SLSA build level 3 provenance. Set TAG to the release you downloaded, e.g. TAG=v0.1.4.

# Verify a downloaded archive against its cosign bundle
cosign verify-blob \
  --bundle iac-coolify_${TAG#v}_linux_amd64.tar.gz.bundle \
  --certificate-identity-regexp 'https://github.com/Rems08/infrastructure-as-coolify/.github/workflows/release.yml@.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  iac-coolify_${TAG#v}_linux_amd64.tar.gz

# Verify the container image
cosign verify ghcr.io/rems08/infrastructure-as-coolify:${TAG#v} \
  --certificate-identity-regexp 'https://github.com/Rems08/infrastructure-as-coolify/.github/workflows/release.yml@.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# Verify the SLSA provenance attached to the release
slsa-verifier verify-artifact \
  --provenance-path multiple.intoto.jsonl \
  --source-uri github.com/Rems08/infrastructure-as-coolify \
  --source-tag "$TAG" \
  iac-coolify_${TAG#v}_linux_amd64.tar.gz

Select environments

plan, apply and destroy operate on every environment found under the config path by default. Use --env <name> to restrict the run to one or more environments, matched against each resource’s metadata.environment:

# A single environment
iac-coolify plan   infra/ --env staging
iac-coolify apply  infra/ --env production --auto-approve

# Several at once (repeat the flag; the result is their union)
iac-coolify apply  infra/ --env staging --env preview --auto-approve

# Compose with --target to act on one named resource within an environment
iac-coolify apply  infra/ --env staging --target back-office --auto-approve

--env composes with --target (logical resource name): a resource is selected only when it passes both filters. A --env value that no resource declares — or a --target/--env combination that matches nothing — exits with code 2, so a typo fails loudly instead of silently doing nothing. Projects are cross-environment and are never filtered by --env.

Both folder layouts work — the filter reads metadata.environment, not the path:

# Nested per environment                       # Flat
infra/                                          infra/
  environments/staging/applications/a.yaml        applications/a.yaml   # environment: staging
  environments/production/applications/b.yaml      applications/b.yaml   # environment: production

Manage databases

apply creates, updates and deletes managed databases alongside applications and services. A database is declared per engine; the engine selects the Coolify create endpoint:

api_version: iac-coolify/v1
kind: Database
metadata:
  name: pg-core
  project: beenaire
  environment: staging
spec:
  engine: postgresql        # postgresql|mysql|mariadb|mongodb|redis|keydb|dragonfly|clickhouse
  image: postgres:18-alpine
  destination:
    server: localhost
    network: coolify
  password: "${env:PG_PASSWORD}"   # opaque Secret; mapped to the engine's credential field
# Create or update the declared databases (ordered after project + environment)
iac-coolify apply infra/ --env staging --auto-approve

# Restrict to one database by logical name
iac-coolify apply infra/ --target pg-core --auto-approve

# Delete databases (reverse dependency order; a full teardown of configs, volumes and networks)
iac-coolify destroy infra/ --env staging --auto-approve

The password is an opaque Secret: it is mapped to the engine-specific credential field (postgres_password, redis_password, mysql_password, …) and revealed only at the HTTP boundary, never in plan/apply output, errors or the audit log. The apply diff compares image, public/public_port, the destination pair and CPU/memory limits; a credential never field-diffs, so rotating a password is an explicit future operation rather than reconciled drift. A destination change is a recreate, not an update — see Move a resource to another server.

MongoDB passwords on create: the pinned Coolify v4 spec exposes no password field on the MongoDB create endpoint (only mongo_initdb_root_username), so a MongoDB password is not sent at creation time. Set it afterwards through Coolify.

Move a resource to another server

The Coolify API has no in-place server move, so destination.server and destination.network are not patchable fields. plan diffs them (projecting the server’s logical name, never its UUID) and marks any change as a recreate, distinct from an ordinary update — in JSON the item carries "requires_recreate": true:

-/+ Application.api must be recreated (destination changed: server "localhost" -> "hetzner-1")
    ~ Application.api.destination.server: "localhost" -> "hetzner-1"

apply refuses to update a resource whose destination changed — no partial PATCH is emitted, so the resource can never end up half-migrated with its old server silently kept:

cannot move application "api" to server "hetzner-1": destroy it first, then re-apply (Coolify has no in-place server move)

The supported choreography is destroy, then re-apply:

iac-coolify destroy infra/ --env staging --target api --auto-approve   # removes it from the old server
# edit destination.server in the manifest
iac-coolify apply infra/ --env staging --target api --auto-approve     # creates it on the new server

A destination.server that references a variable ("${env:STAGING_SERVER}") stays stable at plan time — references are resolved only at apply, so an unresolved reference never shows up as a phantom recreate, and destroy works without the variable set.

Interpolate environment variables

Any visible (Param) string field can reference an environment variable with ${env:VAR}metadata names, image.name/image.tag, fqdn, a git source, an inline dockerfile, destination, limits, and plain env_vars values. References are resolved at apply, just before the value is pushed; an unset variable is then an error (no silent fallback to ""). validate, plan and explore keep the reference unresolved, so they need no variable set.

metadata:
  environment: "${env:DEPLOY_ENV}"
spec:
  image:
    tag: "${env:IMAGE_TAG}"
  fqdn: "https://${env:PUBLIC_HOST}"

Param fields are visible in plan/apply output once resolved. Never put a secret in a Param field — use value_secret (see Manage secrets) for sensitive values.

Manage secrets

Secrets are an opaque Secret type: shown as [REDACTED] in every log, plan, apply output, error, and the state cache — only their origin (${env:NAME}) is ever displayed. A literal secret in YAML is rejected at parse time. The defences layer up:

LayerWhat
L0 Opaque Secret typeredaction by construction (the value has no visible accessor)
L1 Env interpolation${env:VAR} in Param fields and value_secret
L2 SOPS + age at rest${sops:path} decrypted in memory from a colocated secrets.enc.yaml
L4 validate --strictflags a secret-like value mistakenly put in a visible value
L5 Redacted logs/planguaranteed by L0; no value can reach output
L6 Audit logappend-only 0600 log of who/what, with secret origins and a diff hash, never values

Ways to supply a value:

env_vars:
  - name: NODE_ENV
    value: "production"                     # visible literal
  - name: LOG_LEVEL
    value: "${env:LOG_LEVEL}"               # visible, env-resolved
  - name: DATABASE_URL
    value_secret: "${env:DATABASE_URL}"     # secret, env-sourced, REDACTED
  - name: STRIPE_KEY
    value_secret: "${sops:stripe.key}"      # secret, SOPS-decrypted, REDACTED

When ${env:…} references resolve

Loading is lenient: validate, plan and explore keep every ${env:…} reference (visible values and value_secret alike) unresolved — a secret carries only its origin — so these read-only commands work even when the secret environment is not set. The values are bound at exactly one place: apply, which resolves them just before pushing. If a referenced variable is unset, apply fails with a clear, resource-named error rather than pushing an empty value; destroy never resolves them. ${sops:…} references are the exception — they are decrypted at load, since that needs the manifest directory.

Because of this, validate (including --strict) checks structure and schema only and no longer reports an unset secret environment variable; run apply to surface a missing one.

SOPS + age secrets at rest

A ${sops:path} reference is read from a secrets.enc.yaml colocated with the manifest (the path is never user-supplied, so there is no traversal surface), decrypted in memory, and shown as [REDACTED]. path is a dotted key into the decrypted document, e.g. databases.staging.password. A worked example lives in examples/secrets-sops/ — its committed secrets.enc.yaml is encrypted to a throwaway recipient, so re-encrypt it with your own key before running it.

# 1. Generate an age key and restrict it (iac-coolify refuses a group/other-readable key).
age-keygen -o ~/.config/sops/age/keys.txt && chmod 600 ~/.config/sops/age/keys.txt

# 2. Put your public key (age1...) in your .sops.yaml, then encrypt:
printf 'databases:\n  staging:\n    password: your-secret\n' \
  | sops --encrypt --input-type yaml /dev/stdin > secrets.enc.yaml

# 3. Validate — the SOPS value resolves and stays REDACTED.
iac-coolify validate coolify.yaml

iac-coolify locates the key via SOPS_AGE_KEY_FILE (or the default ~/.config/sops/age/keys.txt). Never commit a private age key — only the encrypted file and the public recipient (.sops.yaml) are safe to track. Add a pattern such as *.age.key to your .gitignore so a key exported next to the manifests can never be committed.

Viewing secret values

iac-coolify never reveals secret values, by design. To inspect one, go to the source — e.g. printenv DATABASE_URL. Secrets stay scoped to the tool that owns them (your shell).

Import existing resources

iac-coolify import [dir] reverse-engineers a live Coolify instance into local YAML manifests, so you can adopt an instance that was built by hand. It runs non-interactively (CI-friendly): point it at the instance with the same credentials the other commands use, and it scaffolds a coolify.yaml root plus an environments/<env>/{applications,databases}/ tree under dir (default: the current directory).

export COOLIFY_API_URL=https://coolify.example.com
export COOLIFY_API_TOKEN=...                  # both required; import has no offline mode
iac-coolify import ./coolify                  # scaffold the whole instance
iac-coolify import ./coolify --env staging    # only the staging environment
iac-coolify import ./coolify --force          # overwrite existing manifests

Import never writes a secret value. Every application environment variable becomes a ${env:KEY} reference and every database password a ${env:NAME_PASSWORD} reference; the final report lists the keys to populate in your own .env. Verify the result with iac-coolify validate ./coolify and iac-coolify plan ./coolify — a faithfully imported resource plans as no change.

FlagMeaning
--default-networkDocker network written on every resource (default coolify; the API does not expose it)
--envImport only one or more environment names (repeatable)
--forceOverwrite existing manifests instead of refusing

Two limits are reported, not silently dropped:

  • Services are not imported. The API exposes only a service’s name and environment, not enough to rebuild its spec (tracked at coollabsio/coolify#10449).
  • Git-based applications are partial. Coolify does not expose the source repository URL, so a placeholder is written and the application is listed as needing a manual edit before it will validate. Applications built from a docker image import completely.

By default import refuses to overwrite any existing file, listing every collision so a re-run never clobbers local edits; pass --force to overwrite.

Explore live state interactively

iac-coolify explore (alias tui) opens a terminal browser over a live Coolify instance. It walks the project → environment → resource tree and inspects each resource. Browsing and the drift view are read-only. It can make two kinds of write: application lifecycle actions (restart/stop/start), each behind a confirmation prompt; and write-back of an application’s desired env vars to its YAML file. There is no create or delete in the browser yet, and write-back never pushes to Coolify — run iac-coolify apply for that.

Prerequisites

explore requires an interactive terminal — in a pipe or CI it exits with an error rather than opening a UI. Credentials can come from the environment, or be entered in the connection wizard when they are missing:

export COOLIFY_API_URL=https://coolify.example.com
export COOLIFY_API_TOKEN=...        # set both to skip the connection wizard
iac-coolify explore                 # browse only
iac-coolify explore ./coolify       # browse + drift against the config under ./coolify

Connection wizard

When no credentials are in the environment, explore opens a connection wizard instead of refusing to start: enter the Coolify URL, the API token, and an optional Cloudflare Access pair (Client ID + Secret). The token and the CF-Access secret are masked as you type, and enter tests the connection before opening the browser — a bad URL or token keeps the wizard open with the (redacted) error so you can fix it. The entered values are used for the session only: they are never written to disk and never logged. esc quits without connecting.

Starting in an empty directory

When explore starts in a directory that holds no resource manifests, it opens an onboarding menu instead of an empty tree:

  • [S]ync imports the live instance into local YAML — the same reverse-engineering as iac-coolify import, writing ${env:…} references (never secret values) and printing a report. After a sync the desired config is loaded straight away, so drift and the desired comparison work in the same session; if manifests already exist a sync asks before overwriting them ([y/N]).
  • [I]nit scaffolds an empty root coolify.yaml.
  • [B]rowse opens the live tree without any local config.
  • [Q]uit exits.

Keys

KeyAction
/, k/jmove the cursor
expand/collapse a container, or open a resource
esc/backspaceclose the active pane (logs, drift, detail), else collapse or jump to the parent
rreveal/hide masked environment-variable values
Ddrift: compare the selected application with its config
R/S/Urestart / stop / start the selected application (asks [y/N])
eedit the selected desired env var
s/dsave edits back to YAML / discard them
/filter env vars by key (esc clears the filter)
Ltoggle the log pane
q/ctrl+cquit

What each resource shows

  • Service — its live environment variables as a table. Every value is masked (••••••) until you press r; pressing it again re-masks them. Revealing is a view toggle on values the read API already returns in cleartext — nothing is decrypted, logged or written.
  • Application — its structural fields (status, image, build pack, …) and, when a config path is given and a desired Application matches by (environment, name), an ENV VARS (desired) section read from the YAML. A plain value is masked until you press r; a secret is shown only by its source declaration (${env:NAME} / ${sops:path}) next to a 🔒 marker. The resolved secret value is never read or displayed — r reveals plain values, never a secret. When no desired Application matches the selected name (or no config path was given), the section says so rather than failing. These rows can be edited and written back to YAML — see Editing desired env vars.
  • Database — its structural fields (status, type, …) only; no environment-variable table on the read path, so the mask toggle does not apply.

Databases that Coolify exposes only through the per-server resource listing carry no project or environment, so they appear under a single top-level databases group rather than nested under a project.

Comparing desired and live env vars

An application’s desired section is compared against its live env vars, joined by name and summarised in the header as N tracked · N only-local · N only-remote. The comparison is by presence, not value (a desired value is often a ${env:…} reference, not a resolved value, so a value-to-value diff would be meaningless):

  • a name found on both sides is tracked and shows the live value beside the desired one (masked until r);
  • a desired name with no live counterpart is only-local;
  • live env vars with no desired counterpart — what is left to capture as IaC — are listed read-only under ENV VARS (only on remote) and tagged only-remote.

If the live env listing fails, the fields and desired section still load and the comparison is reported as unavailable. Live values are masked by default like everything else.

Coolify scopes each live env var as buildtime, runtime, or both, so rows carry a scope tag (KEY [build], KEY [runtime], KEY [build,runtime]). The same key in two scopes is a genuine variant and is kept as two rows; exact (key, scope) duplicates are collapsed, and a collapsed pair that disagreed on its value is flagged ⚠ conflicting values (the value itself is never shown). The only-remote presence count is by unique key, so a key present in both scopes counts once.

Press / to filter the env lists by a key substring (case-insensitive — keys only, never values); the header shows filter: <query> (matched/total) and esc clears it. A long only-remote list scrolls within a fixed window with ↑ N more/↓ N more markers: /j runs the cursor through the editable desired rows and then scrolls the list, /k scrolls back.

Drift

Press D on an application to compute its drift: the per-field difference between the desired YAML config and the live resource. The desired and live sides are matched by the resource’s logical name (metadata.name), so it needs no file mapping — and it is purely a read. Additions, updates and deletions are colour-coded; secret fields are reported by their source declaration only and never shown in cleartext.

Drift needs a config path. Pass it as the argument to explore (e.g. explore ./coolify). Without one — or when no desired Application matches the selected name — the pane says drift is unavailable rather than failing.

Lifecycle actions

R, S and U restart, stop and start the selected application. Each first opens a confirmation prompt (restart application "web"? [y/N]) that captures every key, so a stray press can neither trigger the action nor quit the program mid-prompt — only y proceeds, n/esc cancels. Every action runs asynchronously and is recorded to the append-only audit log (set with --audit-log, default .iac-coolify/audit.log); the entry names the action and resource and carries no secret.

Editing desired env vars

When an application’s ENV VARS (desired) section is shown, / move a cursor over the rows and e opens the selected one for editing in a text field that captures every key, so a stray q or s cannot quit or save mid-typing — confirms, esc cancels.

  • A plain value is pre-filled with the literal and edited freely.
  • A secret is pre-filled with its source declaration (${env:NAME} / ${sops:path}) and edited as a reference: the input must stay a ${env:}/${sops:} reference or the edit is rejected. The browser never holds a secret’s resolved value, so it can neither display nor write one — a secret can only ever be re-pointed at another reference.

Confirmed edits are staged in memory and the changed rows are marked *; nothing touches disk until you press s. s writes the patched manifest back to its YAML file with an atomic temp-file-then-rename and records a write-back entry to the audit log; d discards the staged edits and restores the on-disk values. Quitting with unsaved edits asks for confirmation first. The write-back edits the desired YAML only — it does not push to Coolify, so run iac-coolify apply to reconcile the live instance afterwards.

Logs

Press L to open a pane showing the structured log records produced during the session (for example, how many resources the resolver found). Logs are routed through the UI rather than printed to the terminal, so they never corrupt the full-screen view.

Run in CI

Pass COOLIFY_API_TOKEN (and any Cloudflare Access service-token headers) as masked CI secrets, and set IAC_COOLIFY_ACTOR so the audit log attributes the change. When you use SOPS, store the age private key as a masked secret (SOPS_AGE_KEY) and write it to an owner-only file pointed at by SOPS_AGE_KEY_FILE. Run plan on pull/merge requests and apply --auto-approve on the protected default branch.

GitHub Actions

Use the published composite action; the version is selected by the action ref:

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Rems08/infrastructure-as-coolify@v1
        with:
          command: plan
          path: coolify/
          env: staging
        # Coolify token, passed from a masked repository secret
        # api-token: ${{ secrets.COOLIFY_API_TOKEN }}

command is one of plan|apply|destroy|validate; env accepts several environments separated by spaces or commas. The action is a Linux Docker action.

GitLab CI

Include the reusable template and extend the jobs you need:

include:
  - remote: "https://raw.githubusercontent.com/Rems08/infrastructure-as-coolify/v0.1.6/.gitlab/templates/iac-coolify.yml"

plan:
  extends: .iac-coolify-plan
apply:
  extends: .iac-coolify-apply       # runs on the default branch and tags
destroy:
  extends: .iac-coolify-destroy     # manual only

Set IAC_COOLIFY_PATH (config directory) and IAC_COOLIFY_ARGS (e.g. --env staging) as CI variables, and store COOLIFY_API_TOKEN as a masked, protected variable.

A plan with pending changes exits 0 — the diff is in the job log and an apply job in a later stage of the same pipeline can still run. For a gating plan that fails (exit 2) on pending changes — e.g. in a merge-request pipeline with no apply stage after it — set IAC_COOLIFY_PLAN_FLAGS: "--detailed-exitcode".

Supplying ${env:} values (one secret per application)

apply resolves every ${env:KEY} reference to a real value and pushes it to Coolify, so each referenced variable must be present in the job environment. Rather than declaring dozens of individual CI variables, store one File-type CI/CD variable per application holding a dotenv blob, and pass it with --env-file:

# CI/CD variable RCA_STAGING_ENV (type: File, protected), content:
DJANGO_SETTINGS_MODULE=config.settings.staging
DATABASE_URL=postgres://...
# ... one KEY=VALUE per referenced env var
apply:
  extends: .iac-coolify-apply
  variables:
    IAC_COOLIFY_ARGS: "--env staging --target my-app --env-file $RCA_STAGING_ENV"

A File variable’s value is the path GitLab writes the content to, so --env-file $RCA_STAGING_ENV points the binary at it. The file is dotenv format (KEY=VALUE, # comments, blank lines, an optional export prefix, optional surrounding quotes; the first = splits, so values may contain =). A real environment variable always wins over the file, so tokens injected by CI (COOLIFY_API_TOKEN, CF_ACCESS_*) are never shadowed. plan accepts --env-file too but does not need it — it compares by reference origin, not resolved value.

Slow or loaded Coolify instances

Each request times out after 30s by default. When the host is loaded or the topology is large, set IAC_COOLIFY_HTTP_TIMEOUT (a Go duration such as 120s or 4m) as a CI variable to raise it. An unparseable value fails the job loudly rather than being ignored.

Environment-per-branch convention (main → production, *-staging → staging)

A common GitOps split is: merge requests from a *-staging branch plan and apply the staging environment, and the default branch applies production. The job rules and --env scope do this — nothing tool-specific is required:

include:
  - remote: "https://raw.githubusercontent.com/Rems08/infrastructure-as-coolify/v0.1.6/.gitlab/templates/iac-coolify.yml"

# Every MR shows the diff for the environment it targets (job is green on no-op).
plan:staging:
  extends: .iac-coolify-plan
  variables: { IAC_COOLIFY_ARGS: "--env staging" }
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /-staging$/'

# A `*-staging` MR pipeline applies staging (staging is the test environment).
# extends brings the apply script; the rules here replace the template's default-branch rule.
apply:staging:
  extends: .iac-coolify-apply
  variables: { IAC_COOLIFY_ARGS: "--env staging" }
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /-staging$/'
      when: manual

# The default branch applies production.
apply:production:
  extends: .iac-coolify-apply
  variables: { IAC_COOLIFY_ARGS: "--env production" }
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

Scope further with --target <name> to pilot a single application before managing the whole topology, e.g. IAC_COOLIFY_ARGS: "--env production --target restaurant-core-api".

Without the action or template

For a runner that cannot use the composite action or the GitLab template — or to pin the binary via go install — start from the raw reference workflows: examples/ci/github-actions.yml and examples/ci/gitlab-ci.yml.

Project

Generated by iac-coolify docs gen from the struct tags in internal/resource. Do not edit by hand.

This is the reference for the Project resource and its nested types. Each field below maps 1:1 to a YAML key.

Project

api_version

API schema version (must be iac-coolify/v1).

  • Required.

kind

Resource kind (must be Project).

  • Required.
  • Allowed values: Project.

metadata

Identifying metadata.

  • Required.

spec

Desired project state.

  • Required.

ProjectMeta

name

Logical name used as the immutable key (lowercase alphanumerics and hyphens).

  • Required.

ProjectSpec

description

Human-readable project description.

Environment

Generated by iac-coolify docs gen from the struct tags in internal/resource. Do not edit by hand.

This is the reference for the Environment resource and its nested types. Each field below maps 1:1 to a YAML key.

Environment

api_version

API schema version (must be iac-coolify/v1).

  • Required.

kind

Resource kind (must be Environment).

  • Required.
  • Allowed values: Environment.

metadata

Identifying metadata.

  • Required.

spec

Desired environment state.

  • Required.

EnvironmentMeta

name

Logical name such as staging or production (lowercase alphanumerics and hyphens).

  • Required.

project

Logical project name this environment belongs to (referenced by name).

  • Required.

EnvironmentSpec

description

Human-readable environment description.

Application

Generated by iac-coolify docs gen from the struct tags in internal/resource. Do not edit by hand.

This is the reference for the Application resource and its nested types. Each field below maps 1:1 to a YAML key.

Application

api_version

API schema version (must be iac-coolify/v1).

  • Required.

kind

Resource kind (must be Application).

  • Required.
  • Allowed values: Application.

metadata

Identifying metadata.

  • Required.

spec

Desired application state.

  • Required.

ApplicationMeta

name

Logical name used as the immutable key.

  • Required.

project

Logical project name (referenced by name).

  • Required.

environment

Environment name such as staging or production.

  • Required.

ApplicationSpec

description

Human-readable application description.

build_pack

Build pack to use.

  • Required.
  • Allowed values: dockerfile, dockerimage, nixpacks, docker-compose, static, railpack.

image

Docker image (required when build_pack is dockerimage).

dockerfile

Inline Dockerfile content for build_pack dockerfile in inline mode; mutually exclusive with source.

source

Public git source for build_pack dockerfile/nixpacks/docker-compose/static/railpack.

destination

Server and network reference.

  • Required.

fqdn

Public URL such as https://app.example.com.

port

Container port exposed (required for build_pack dockerimage).

health_check

HTTP health check configuration.

limits

CPU and memory limits.

preview

Pull-request preview configuration.

env_vars

Inline environment variables.

env_vars_from

Names of EnvVar resources whose variables are merged into this application.

ImageSpec

name

Docker image name including the registry path.

  • Required.

tag

Docker image tag.

  • Required.

SourceSpec

git_repository

Public git repository URL (https:// http:// or git@).

  • Required.

git_branch

Git branch to deploy.

  • Required.

ports_exposes

Ports the build exposes such as 3000.

  • Required.

DestinationRef

server

Server name (logical reference).

  • Required.

network

Docker network name.

  • Required.

HealthCheckSpec

enabled

Whether the health check is enabled.

path

HTTP path to probe such as /health.

LimitsSpec

cpu_shares

Relative CPU shares.

memory

Memory limit such as 512m.

PreviewSpec

url_template

Preview URL template such as {{pr_id}}.{{domain}}.

EnvVarEntry

name

Variable name.

  • Required.

value

Visible value (supports ${env:VAR} interpolation); use value_secret for sensitive values.

value_secret

Sensitive value; MUST be ${env:NAME} or ${sops:path} and is shown as [REDACTED].

Service

Generated by iac-coolify docs gen from the struct tags in internal/resource. Do not edit by hand.

This is the reference for the Service resource and its nested types. Each field below maps 1:1 to a YAML key.

Service

api_version

API schema version (must be iac-coolify/v1).

  • Required.

kind

Resource kind (must be Service).

  • Required.
  • Allowed values: Service.

metadata

Identifying metadata.

  • Required.

spec

Desired service state.

  • Required.

ServiceMeta

name

Logical name used as the immutable key.

  • Required.

project

Logical project name (referenced by name).

  • Required.

environment

Environment name such as staging or production.

  • Required.

ServiceSpec

destination

Server reference the stack runs on.

  • Required.

description

Human-readable service description.

fqdn

Public URL such as https://app.example.com; Coolify binds domains per compose sub-service so this is advisory and not applied on create yet.

instant_deploy

Start the service immediately after creation (default false).

docker_compose_path

Relative path to a docker-compose.yml in the repository (mutually exclusive with type).

type

Coolify one-click template identifier such as gitea-with-mysql (mutually exclusive with docker_compose_path).

env_vars

Environment variables applied to the service.

ServiceDestination

server

Server name (logical reference).

  • Required.

Database

Generated by iac-coolify docs gen from the struct tags in internal/resource. Do not edit by hand.

This is the reference for the Database resource and its nested types. Each field below maps 1:1 to a YAML key.

Database

api_version

API schema version (must be iac-coolify/v1).

  • Required.

kind

Resource kind (must be Database).

  • Required.
  • Allowed values: Database.

metadata

Identifying metadata.

  • Required.

spec

Desired database state.

  • Required.

DatabaseMeta

name

Logical name used as the immutable key.

  • Required.

project

Logical project name (referenced by name).

  • Required.

environment

Environment name such as staging or production.

  • Required.

DatabaseSpec

description

Human-readable database description.

engine

Database engine.

  • Required.
  • Allowed values: postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.

version

Engine version used as the image tag such as 16.

image

Custom Docker image overriding the default engine image.

destination

Server and network reference.

  • Required.

public

Whether the database is exposed on a public port.

public_port

Public port (required when public is true).

limits

CPU and memory limits.

password

Database password; MUST be ${env:NAME} or ${sops:path} and is shown as [REDACTED].

EnvVar

Generated by iac-coolify docs gen from the struct tags in internal/resource. Do not edit by hand.

This is the reference for the EnvVar resource and its nested types. Each field below maps 1:1 to a YAML key.

EnvVar

api_version

API schema version (must be iac-coolify/v1).

  • Required.

kind

Resource kind (must be EnvVar).

  • Required.
  • Allowed values: EnvVar.

metadata

Identifying metadata.

  • Required.

spec

Desired environment-variable set.

  • Required.

EnvVarMeta

name

Logical name referenced by an Application env_vars_from.

  • Required.

project

Logical project name (referenced by name).

  • Required.

environment

Environment name such as staging or production.

  • Required.

EnvVarSpec

vars

Environment-variable entries (see Application env_vars).

  • Required.

Why iac-coolify

Coolify is an excellent self-hosted PaaS, but its state lives in a UI and a REST API. As soon as more than one person — or one environment — is involved, you want the same things Terraform gives you for cloud resources: a reviewable description of intent, a preview of what will change, and a repeatable apply. iac-coolify brings that model to Coolify natively.

iac-coolifycoollabsio/coolify-cliSierraJC/terraform-provider-coolify
Modeldeclarative YAMLimperative wrapperdeclarative HCL
plan/apply/destroyplan + apply + destroy
State filestateless-firstn/atfstate required
Native to Coolifywraps Terraform

Stateless-first

There is no state file to store, lock, or drift from reality. iac-coolify resolves the live Coolify UUIDs from your logical metadata names at run time and diffs against the actual remote state. Logical names — never UUIDs — live in your YAML, so a config stays portable across Coolify instances.

Idempotent by construction

A second apply on an unchanged state is a no-op, and a second destroy on already-removed resources is too (a 404 on delete is success). Every write carries an Idempotency-Key, so a retried apply cannot create duplicates.

Secrets that cannot leak

Secret values use an opaque type with no visible accessor, so they are [REDACTED] in every log, plan, error and the state cache by construction — not by remembering to redact. See Manage secrets.

The configuration model

Resources are described in a directory tree, one file per resource, using Kubernetes-style documents (api_version/kind/metadata/spec):

coolify/
  coolify.yaml                                   # global config (api_url, required_coolify)
  project.yaml                                   # kind: Project
  environments/<env>/environment.yaml            # kind: Environment
  environments/<env>/applications/<app>.yaml     # kind: Application
  environments/<env>/databases/<db>.yaml         # kind: Database (8 engines)
  environments/<env>/envvars/<set>.yaml          # kind: EnvVar (shared, via env_vars_from)
  services/<svc>.yaml                            # kind: Service (docker-compose stack)

Supported resource kinds: Project, Environment, Application, Service, Database (postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse), and standalone EnvVar sets that an Application merges in by name (env_vars_from). apply creates projects and environments before the applications and services that reference them. The full field-by-field schema for each kind is in the reference.

Applications and build packs

An Application is built by one of six build packs, each with its own source of truth:

  • dockerimage — a prebuilt image (image.name + image.tag).
  • dockerfile — either an inline dockerfile (Dockerfile content, no git) or a git source; exactly one of the two.
  • nixpacks, docker-compose, static, railpack — a public git source (git_repository + git_branch + ports_exposes).

A git source.git_repository must use an https://, http:// or git@ URL; an inline dockerfile is capped at 1 MB. The IaC build pack docker-compose is sent to Coolify under its upstream spelling dockercompose.

Private git sources: applications from a private GitHub App or deploy key are not yet supported (they need a separate credential resolver). Use a public repository, an inline Dockerfile, or a prebuilt image.

Services

A Service is a docker-compose stack. It sources its definition from exactly one of a docker_compose_path (a relative path to a compose file in your repository, which may not resolve outside the config tree) or a type (a Coolify one-click template identifier such as gitea-with-mysql).

Service domains: Coolify binds domains per docker-compose sub-service, so a Service’s fqdn is advisory metadata for now and is not applied on create.

Validation at the boundary

Configuration is validated where it is parsed, not where it is used: validate checks YAML syntax, semantic rules, and (with --strict) flags secret-like values placed in visible fields. After validation, the internal engine trusts the typed structs — there are no defensive fallbacks scattered through plan and apply.