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
| OS | amd64 | arm64 |
|---|---|---|
| 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
- Select environments to scope a run.
- Manage secrets so sensitive values never reach output.
- Browse the reference for every field.
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 MongoDBpasswordis 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:
| Layer | What |
|---|---|
L0 Opaque Secret type | redaction 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 --strict | flags a secret-like value mistakenly put in a visible value |
| L5 Redacted logs/plan | guaranteed by L0; no value can reach output |
| L6 Audit log | append-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.
| Flag | Meaning |
|---|---|
--default-network | Docker network written on every resource (default coolify; the API does not expose it) |
--env | Import only one or more environment names (repeatable) |
--force | Overwrite 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]yncimports the live instance into local YAML — the same reverse-engineering asiac-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]nitscaffolds an empty rootcoolify.yaml.[B]rowseopens the live tree without any local config.[Q]uitexits.
Keys
| Key | Action |
|---|---|
↑/↓, k/j | move the cursor |
↵ | expand/collapse a container, or open a resource |
esc/backspace | close the active pane (logs, drift, detail), else collapse or jump to the parent |
r | reveal/hide masked environment-variable values |
D | drift: compare the selected application with its config |
R/S/U | restart / stop / start the selected application (asks [y/N]) |
e | edit the selected desired env var |
s/d | save edits back to YAML / discard them |
/ | filter env vars by key (esc clears the filter) |
L | toggle the log pane |
q/ctrl+c | quit |
What each resource shows
- Service — its live environment variables as a table. Every value is masked
(
••••••) until you pressr; 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 pressr; 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 —rreveals 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
trackedand shows the live value beside the desired one (masked untilr); - 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 genfrom the struct tags ininternal/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 genfrom the struct tags ininternal/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 genfrom the struct tags ininternal/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 genfrom the struct tags ininternal/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 genfrom the struct tags ininternal/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 genfrom the struct tags ininternal/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-coolify | coollabsio/coolify-cli | SierraJC/terraform-provider-coolify | |
|---|---|---|---|
| Model | declarative YAML | imperative wrapper | declarative HCL |
plan/apply/destroy | ✅ plan + apply + destroy | ❌ | ✅ |
| State file | stateless-first | n/a | tfstate required |
| Native to Coolify | ✅ | ✅ | wraps 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 inlinedockerfile(Dockerfile content, no git) or a gitsource; exactly one of the two.nixpacks,docker-compose,static,railpack— a public gitsource(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
fqdnis 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.