skip to content
wihan.dev

Managing Dokploy with a Terraform provider

The whole stack behind this site is one Terraform repo - project, environments, apps, domains, DNS - and the goal state is an empty plan.

4 minterraform · dokploy · infra

Everything behind this site is declared in one Terraform repo: the Dokploy project, a prod and a dev environment, the applications, their domains, and the Cloudflare DNS records that point at them. One terraform plan shows the whole estate. The goal state is an empty plan, and reaching it after every change is the entire workflow.

Here’s how to set that up with the vanillauys/dokploy provider.

1. Install the provider

hcl
terraform {
  required_providers {
    dokploy = {
      source  = "vanillauys/dokploy"
      version = "~> 1.8"
    }
  }
}

💡 The provider follows semantic versioning from v1.0.0: a minor release only adds resources and attributes, so ~> 1.8 gets every fix and addition without a breaking change. The changelog names every change.

2. Point it at your server

Mint an API key in the Dokploy UI under Settings → API/CLI, and keep it out of the repo - the provider reads it from the environment:

hcl
provider "dokploy" {
  endpoint = "https://dokploy.example.com"
  # api_key comes from the DOKPLOY_API_KEY environment variable
}
bash
export DOKPLOY_API_KEY="$(op read 'op://wihan_dev/dokploy/api key')"
terraform init

That op read is 1Password’s CLI, which is how this repo does it; any secret store that can put a value in an environment variable works the same way.

3. Declare the environments as data

There will always be exactly two environments here, so they live in a map and a module handles the rest:

hcl
resource "dokploy_environment" "dev" {
  project_id = dokploy_project.site.id
  name       = "dev"
}

locals {
  environments = {
    prod = {
      environment_id = dokploy_project.site.production_environment_id
      branch         = "master"
      hosts          = ["wihan.dev", "www.wihan.dev"]
    }
    dev = {
      environment_id = dokploy_environment.dev.id
      branch         = "dev"
      hosts          = ["dev.wihan.dev"]
    }
  }
}

module "app" {
  source   = "./modules/web-service"
  for_each = local.environments

  name           = "wihan-dev-app"
  environment_id = each.value.environment_id
  branch         = each.value.branch
  hosts          = each.value.hosts
}

The module creates the Dokploy application, one domain per host, and one proxied CNAME per host. Adding an environment is one map entry, which is exactly the amount of ceremony it deserves. Code deploys stay with Dokploy: push to a branch, the webhook fires, the right environment rebuilds. A configuration change deploys on apply, and the apply waits for the deploy to finish. Set deploy_on_change = false for an apply that must not redeploy.

Production runs two replicas. Dokploy runs every service on Docker Swarm, and the provider’s swarm block sets the swarm service specification. Inside the module, a swarm_replicas variable picks the mode, and prod sets it to 2:

hcl
resource "dokploy_application" "this" {
  # ...
  # The provider rejects replicas together with swarm.mode, so exactly one
  # of the two carries the task count.
  replicas = var.swarm_replicas == null ? 1 : null
  swarm = var.swarm_replicas == null ? null : {
    mode = { replicated = { replicas = var.swarm_replicas } }
    update_config = {
      parallelism = 1
      order       = "start-first"
    }
  }
}

With order = "start-first", a deploy starts the new task before it stops the old one, so a rebuild never leaves the site without a running container. Traefik balances the requests across the two tasks.

4. Avoid the two traps

Both of these applied cleanly and still broke something.

Don’t reach through a resource’s computed collections. The project resource returns its environments list, but that list is unknown in the plan of every project update. A for expression over it goes unknown too, and an unknown environment_id replaces every service that uses it: a changed project description planned a destroy and re-create of the app. Use the attribute built for this instead. It keeps its value through a project update, so the plan settles:

hcl
# The production environment that Dokploy creates with the project:
environment_id = dokploy_project.site.production_environment_id

# Any environment you declare yourself:
environment_id = dokploy_environment.dev.id

Compose services get their reverse-proxy labels injected at deploy time. Add a domain to an already-running stack and Terraform applies cleanly while the router serves a 404, because the running containers never heard about it. The compose resource deploys when its own settings change, but a domain is a separate resource, so a new domain starts no deploy. The fix is one redeploy after the domain lands:

bash
curl -s -X POST "$DOKPLOY/api/compose.redeploy" \
  -H "x-api-key: $DOKPLOY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"composeId": "<compose-id>"}'

Terraform tells you what is declared; it cannot tell you what the containers were told.

5. Drive the plan to empty

The loop that keeps the estate honest:

bash
terraform plan

After every change - in Terraform, or clicked in the Dokploy UI in a weak moment - run the plan and make it empty again: either apply the declared state or import what you clicked. An empty plan means the repo and the server agree, and that is the whole point.

Building the provider itself is its own story.