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
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.8gets 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:
provider "dokploy" {
endpoint = "https://dokploy.example.com"
# api_key comes from the DOKPLOY_API_KEY environment variable
}export DOKPLOY_API_KEY="$(op read 'op://wihan_dev/dokploy/api key')"
terraform initThat 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:
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:
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:
# 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.idCompose 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:
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:
terraform planAfter 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.