September 7, 2026

Coolify as code: I wrote a Pulumi provider for my self-hosted stack

By Manuel Christlieb — Staff Engineer

I self-host more than I probably should. This blog used to run on Coolify, and my company tooling still does: a Mattermost instance, a knowledge base, a marketing tool, n8n, a handful of databases, backups to R2, notifications into chat. All of it created by clicking through the Coolify UI, one service at a time, on two servers.

Coolify is genuinely good at that. The problem was me. After a year of clicking I could not answer simple questions anymore. Which databases actually have a backup schedule? Which ones upload to S3? Which services still point at the old SMTP password? Nothing was written down anywhere except in Coolify’s database, and the only way to find out was to open every resource and look.

I wanted an inventory. Something in git that says what runs where, that I can diff in a pull request, and that I can apply to a fresh Coolify instance if a server dies. I already use Pulumi for my Cloudflare zones and my GitHub organisation, so the obvious move was to put Coolify in the same program. There was no provider for it, so I wrote one: pulumi-provider-coolify.

What it manages

The provider is written in Go on top of pulumi-go-provider and talks to Coolify’s public API with a client generated from Coolify’s own OpenAPI specification. It ships a TypeScript SDK as @bambamboole/coolify on npm, and the plugin binary is downloaded from the GitHub release on demand, so there is nothing to install by hand.

It covers the things I actually had in my instance: projects and environments, servers and their SSH keys, standalone databases, applications from an image or a git repository, services from a compose file, S3 storages, database and volume backups, scheduled tasks, shared variables, and the notification channels of a team. Plus two small resources that trigger deployments.

The design decision I care about most: create adopts. When you declare a project called Homelab and one with that name already exists, the provider does not fail and does not create a duplicate. It picks up the existing project, records its UUID, and reconciles the settings you declared. Same for servers by name, databases by name within their environment, backup schedules by frequency, S3 storages by name. That is what made it possible to bring an instance with dozens of hand-made resources under management without a single pulumi import.

The second decision follows from the first. An input you leave out is unmanaged. Coolify keeps its own default, the provider never overwrites it, and pulumi refresh does not report it as drift. I only want to describe the things I care about, not every one of the forty fields a service has.

What it looks like

Here is a small stack: one server, one project, a PostgreSQL database, an application from a Docker image, nightly backups to an R2 bucket, and failures posted to chat. This is not my real setup, but every piece of it is lifted from one.

import * as pulumi from "@pulumi/pulumi";
import * as coolify from "@bambamboole/coolify";

const config = new pulumi.Config();

const provider = new coolify.Provider("coolify", {
    baseUrl: config.require("coolifyBaseUrl"),
    apiToken: config.requireSecret("coolifyApiToken"),
});

const options: pulumi.CustomResourceOptions = { provider, protect: true };

const key = new coolify.PrivateKey("hetzner", {
    name: "hetzner",
    description: "Deploy key for the Hetzner box",
}, options);

const server = new coolify.Server("hetzner-1", {
    name: "hetzner-1",
    ip: "203.0.113.10",
    user: "root",
    port: 22,
    privateKeyUuid: key.uuid,
}, options);

const project = new coolify.Project("homelab", {
    name: "Homelab",
    environments: ["production"],
}, options);

Note that the private key resource has no privateKey input. The key already exists in Coolify, so the resource adopts it by name and never touches the key material. If you declare a key that does not exist yet, you pass the material as a secret and the provider creates it.

Everything below lives in the same project and environment on the same server, so I put the placement in one object and spread it:

const placement = {
    projectUuid: project.uuid,
    environmentName: "production",
    serverUuid: server.uuid,
};

const postgres = new coolify.Database("umami-db", {
    ...placement,
    type: coolify.DatabaseType.PostgreSQL,
    name: "umami-db",
    image: "postgres:18-alpine",
    isPublic: false,
    instantDeploy: true,
}, options);

const umami = new coolify.Application("umami", {
    ...placement,
    name: "umami",
    source: coolify.ApplicationSource.DockerImage,
    dockerRegistryImageName: "ghcr.io/umami-software/umami",
    dockerRegistryImageTag: "postgresql-v2.18.1",
    domains: "https://stats.example.com",
    portsExposes: "3000",
    environmentVariables: {
        DATABASE_URL: pulumi.secret(postgres.internalUrl),
        APP_SECRET: config.requireSecret("umamiAppSecret"),
    },
}, options);

new coolify.Deployment("umami", {
    application: umami.uuid,
    triggers: ["postgresql-v2.18.1"],
}, { provider });

Coolify generates the database credentials, and the provider reads them back as outputs. postgres.internalUrl is the connection string on the internal Docker network, so the application never needs a password in config. The Deployment resource is deliberately separate from the application: changing an environment variable in Coolify does not restart anything, and I want to decide when a redeploy happens. Any change to triggers does it, so the image tag is a natural trigger.

Backups are two resources. The S3 storage points at an R2 bucket, the backup schedule points at the storage:

const backups = new coolify.S3Storage("r2-backups", {
    name: "R2 backups",
    endpoint: `https://${config.require("cloudflareAccountId")}.eu.r2.cloudflarestorage.com`,
    bucket: "homelab-backups",
    region: "auto",
    accessKey: config.requireSecret("r2AccessKeyId"),
    secretKey: config.requireSecret("r2SecretAccessKey"),
}, options);

new coolify.DatabaseBackup("umami-db", {
    databaseUuid: postgres.uuid,
    frequency: "daily",
    enabled: true,
    saveS3: true,
    s3StorageUuid: backups.uuid,
    retentionDaysLocally: 7,
    retentionDaysS3: 30,
}, options);

And the part that finally answered “who gets told when this breaks”:

new coolify.NotificationSlack("mattermost", {
    enabled: true,
    webhookUrl: config.requireSecret("mattermostWebhookUrl"),
    events: {
        deploymentSuccess: false,
        deploymentFailure: true,
        backupFailure: true,
        serverUnreachable: true,
        serverDiskUsage: true,
    },
}, options);

Mattermost speaks the Slack webhook format, so the Slack channel is what I use. Coolify has exactly one settings object per team and channel, and there is no endpoint to delete it. Destroying this resource therefore disables delivery and leaves the configuration in place, which is the least surprising thing I could come up with.

For a compose-based service the shape is the same, with dockerCompose holding the file and domains mapping compose service names to URLs. Since Coolify does not return the compose file through the API, the provider cannot detect drift on it. It sends the file on create and whenever the input changes, and that is it. I hash the compose file and put the hash into a ServiceDeployment’s triggers so a changed file restarts the containers.

Where the state lives

An infrastructure program needs somewhere to keep its state, and with a provider like this the state also contains secrets: database passwords, the Coolify token, webhook URLs. I did not want to run a state bucket with its own credentials just to manage the thing that runs my other things.

Pulumi Cloud’s Individual plan turned out to be enough. It is free, holds the state, encrypts secrets with a key Pulumi manages, and gives you unlimited stacks and update history for one user. Every pulumi up shows up with a diff and a link, which is my audit log.

The Coolify token and the other secrets go into a Pulumi ESC environment instead of the stack’s config file. The stack references the environment in Pulumi.yaml:

name: homelab
runtime:
    name: nodejs
    options:
        typescript: true
main: index.ts
environment:
  - homelab/production

And the environment itself is a short YAML document that lives in Pulumi Cloud:

values:
  pulumiConfig:
    coolifyBaseUrl: https://coolify.example.com
    coolifyApiToken:
      fn::secret: <COOLIFY_API_TOKEN>
    mattermostWebhookUrl:
      fn::secret: <WEBHOOK_URL>

Everything under pulumiConfig is what config.require and config.requireSecret read in the program. The free plan allows 25 secrets in ESC. I am nowhere near that, and if I get there I will have a different problem than money.

The one thing I miss are Pulumi Cloud webhooks, which are a Team feature. I wanted a “deployment done” message in chat, so the GitHub Actions job posts it itself after pulumi up. A few lines of shell and jq, no big loss.

Preview on pull request, apply on main

The program lives in a repository with two jobs. Pull requests run pulumi preview and get the diff as a comment. Merges to main run pulumi up.

jobs:
  preview:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with: { node-version: '24', cache: npm }
      - run: npm ci
      - uses: pulumi/actions@v7
        with:
          command: preview
          stack-name: bambamboole/homelab/production
          refresh: true
          diff: true
          comment-on-summary: true
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

  deploy:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with: { node-version: '24', cache: npm }
      - run: npm ci
      - uses: pulumi/actions@v7
        with:
          command: up
          stack-name: bambamboole/homelab/production
          refresh: true
          diff: true
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

The only secret in GitHub is the Pulumi access token. The Coolify token never leaves ESC. refresh: true matters with a provider that adopts things: before every run Pulumi asks Coolify what is actually there, so a database somebody deleted in the UI shows up as “will be created” in the preview instead of surprising me later.

This is also the inventory I was after. The pull request that adds a backup schedule is the record that the backup exists. When I want to know which services upload to S3, I grep the repository.

What the Coolify API taught me

Writing a provider means learning an API in more detail than its authors probably intended. A few things that shaped the provider:

  • Some things are write-only. Compose files, volume backup schedules and the S3 storage of a database backup are accepted but never returned. The provider sends them and trusts they stuck. Drift on them is invisible to refresh.
  • Private keys cannot be patched. The update endpoint cannot address a key, so a changed key is a replacement, and the description is only applied on create.
  • Deleting a volume backup deletes the archives. All of them, local and S3. The resource is documented accordingly, and I use retainOnDelete: true on it so removing it from the program keeps the backups.
  • Hidden values stay hidden. Environment variables and notification secrets are masked in responses, so the provider manages variables by key and compares secret changes against its own recorded state rather than against Coolify’s.

None of that is a complaint. Coolify’s API grew out of its UI and it shows, but it is complete enough that I could put everything I run under management, and the Coolify team keeps adding endpoints. Shared variables and notification settings only arrived in v4.3.0.

Try it

npm install @bambamboole/coolify

Create a read/write API token under Security in Coolify, point the provider at your instance, and declare the project you already have. The first pulumi up should adopt it and change nothing. From there, add one resource at a time.

The code is at github.com/bambamboole/pulumi-provider-coolify, the README lists every resource with its adoption rule and the behaviour worth knowing. I run my own infrastructure on it, so it will keep getting the resources I need. If you need one I do not, open an issue.