# 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.

- date: 2026-08-30
- tags: terraform, 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`](/docs/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](/docs/dokploy/guides/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](/writing/creating-a-dokploy-terraform-provider).