Files
terraform-provider-dokploy/README.md
T
max-voitcov 2d1caf6e73
build / build (push) Successful in 3m56s
release / release (push) Successful in 15m2s
Cover what Dokploy v0.30 added
Six new resources, all backed by endpoints that did not exist before v0.30.0
and verified end-to-end against a live v0.30.2 instance:

  dokploy_network         Docker networks, now first-class. Services attach
                          through network_ids, which is what deprecates
                          Compose's isolated_deployment upstream.
  dokploy_dns_provider    Cloudflare or Route53, so adding a domain creates
                          its DNS record.
  dokploy_vault_provider  Env values resolved from HashiCorp Vault, Infisical,
                          AWS, Doppler, Azure or Scaleway at deploy time, so
                          the secret never lands in Dokploy or in state.
  dokploy_schedule        Cron jobs in a container, a stack, or on a server.
  dokploy_volume_backup   Scheduled backups of a named volume — the companion
                          to a mount that persists.
  dokploy_libsql          The sixth managed database engine.

libsql.create is the strictest endpoint in the API: eleven keys required to
be present, several only meaningfully null, no generated service name, and it
returns `true` rather than the row. CreateDefaults and ListIDs absorb all
three so the resource behaves like every other database.

Also filled the gaps a field-by-field diff against the live schema turned up:
domain gains `enabled` (the v0.30.0 park-a-domain toggle), compose gains
create_env_file, icon and service_networks, application gains icon and
preview_require_collaborator_permissions, and mounts accept libsql.

DNS and vault credentials are masked by Dokploy on read, so `config` is
tagged noread and keeps the configured value, as the basic-auth password
already does.
2026-08-26 00:40:27 +03:00

22 KiB

terraform-provider-dokploy

Manage Dokploy as code: projects, environments, applications, Compose stacks, managed databases, domains, mounts, ports, redirects, basic auth, registries, SSH keys, certificates and backup destinations.

Works with Terraform and OpenTofu. Because it is a real Terraform provider (built on terraform-plugin-framework), it can also be bridged into Pulumi without rewriting anything.

resource "dokploy_project" "shop" {
  name = "shop"
}

resource "dokploy_postgres" "db" {
  name              = "shop-db"
  environment_id    = dokploy_project.shop.default_environment_id
  docker_image      = "postgres:16-alpine"
  database_name     = "shop"
  database_user     = "shop"
  database_password = var.db_password
}

resource "dokploy_application" "api" {
  name           = "api"
  environment_id = dokploy_project.shop.default_environment_id
  source_type    = "docker"
  docker_image   = "ghcr.io/acme/api:1.4.0"

  env = "DATABASE_URL=postgresql://shop:${var.db_password}@${dokploy_postgres.db.app_name}:5432/shop"
}

resource "dokploy_domain" "api" {
  application_id   = dokploy_application.api.id
  host             = "api.example.com"
  port             = 3000
  https            = true
  certificate_type = "letsencrypt"
}

Contents

Installing

This provider is distributed through Gitea releases and installed through a filesystem mirror, which is Terraform's supported way to use a provider that no registry serves.

Gitea does list "Terraform" among its package registries, which is misleading here: that one stores remote state for the http backend. It is not a module registry and not a provider registry, and there is no Gitea endpoint that can serve the provider protocol terraform init speaks.

From a release

VERSION=0.2.0
OS_ARCH="$(go env GOOS)_$(go env GOARCH)"
BASE=https://gitea.coolify.vojtkov.dev/usr_unknown/terraform-provider-dokploy/releases/download

DEST=~/.local/share/terraform/plugins/registry.terraform.io/maxvojtkov/dokploy/$VERSION/$OS_ARCH
mkdir -p "$DEST"
curl -fsSLO "$BASE/v$VERSION/terraform-provider-dokploy_${VERSION}_${OS_ARCH}.zip"
unzip -j "terraform-provider-dokploy_${VERSION}_${OS_ARCH}.zip" -d "$DEST"

Each release also carries a _SHA256SUMS file, worth checking before you unzip. There are no GPG signatures: those are required by the registry protocol, and a filesystem mirror never verifies them, so publishing one would imply a check that nothing performs.

From source

make install

That writes the binary to ~/.local/share/terraform/plugins/registry.terraform.io/maxvojtkov/dokploy/0.2.0/<os>_<arch>/ and prints the CLI configuration to add to ~/.terraformrc:

provider_installation {
  filesystem_mirror {
    path    = "/Users/you/.local/share/terraform/plugins"
    include = ["registry.terraform.io/maxvojtkov/dokploy"]
  }
  direct {
    exclude = ["registry.terraform.io/maxvojtkov/dokploy"]
  }
}

Then in your configuration:

terraform {
  required_providers {
    dokploy = {
      source  = "maxvojtkov/dokploy"
      version = "0.2.0"
    }
  }
}

Configuring

Generate an API token in Dokploy under Settings → Profile → API/CLI.

provider "dokploy" {
  host    = "https://dokploy.example.com"
  api_key = var.dokploy_api_key
}

Both are better supplied through the environment, which keeps the token out of your configuration entirely:

Attribute Environment variable Default
host DOKPLOY_HOST —
api_key DOKPLOY_API_KEY —
timeout_seconds DOKPLOY_TIMEOUT_SECONDS 60
insecure_skip_verify — false
export DOKPLOY_HOST=https://dokploy.example.com
export DOKPLOY_API_KEY=...
terraform apply

How Dokploy's model maps to Terraform

Dokploy nests everything under a project:

project
└── environment            (a default "production" one is created for you)
    ├── application        ├── domain, mount, port, redirect, security
    ├── compose            └── domain, mount
    └── postgres | mysql | mariadb | mongo | redis

Two consequences worth knowing up front:

Every project comes with a default environment. Creating a dokploy_project also creates a production environment server-side. Rather than making you import it, the provider exposes it as default_environment_id:

resource "dokploy_project" "shop" {
  name = "shop"
}

resource "dokploy_application" "api" {
  environment_id = dokploy_project.shop.default_environment_id
  # ...
}

Declare dokploy_environment only for additional environments such as staging. Declaring one named production would create a second environment with the same name, not adopt the existing one.

Services attach to environments, not projects. environment_id is always the parent reference, and changing it forces replacement — Dokploy moves services through a separate move endpoint that has no declarative equivalent.

Resources and data sources

Full reference documentation lives in docs/.

Resources

Resource Purpose
dokploy_project Top-level container
dokploy_environment Additional environments within a project
dokploy_application A service built from Git, a Docker image or an upload
dokploy_compose A Docker Compose or Swarm stack
dokploy_postgres Managed PostgreSQL
dokploy_mysql Managed MySQL
dokploy_mariadb Managed MariaDB
dokploy_mongo Managed MongoDB
dokploy_redis Managed Redis
dokploy_libsql Managed libSQL (sqld)
dokploy_domain A hostname routed through Traefik
dokploy_mount Volume, bind mount or config file
dokploy_port A port published straight onto the host
dokploy_redirect A Traefik redirect rule
dokploy_security HTTP basic auth credentials
dokploy_registry A container registry
dokploy_ssh_key SSH key pair for private Git and remote servers
dokploy_certificate An uploaded TLS certificate
dokploy_destination S3-compatible backup destination
dokploy_network A Docker network services attach to
dokploy_schedule A cron job run in a container or on a server
dokploy_volume_backup A scheduled backup of a Docker volume
dokploy_dns_provider Cloudflare or Route53, for automatic DNS records
dokploy_vault_provider An external secret manager for deploy-time env

Data sources

Data source Purpose
dokploy_project Look up a project and its environments
dokploy_projects List every visible project
dokploy_environment Look up a single environment
dokploy_application Look up a single application
dokploy_servers List registered remote servers

The web-service module

modules/web-service bundles the pieces a typical public service needs — the application plus its domains, ports, mounts, basic auth and redirects — behind one call:

module "storefront" {
  source = "git::https://gitea.coolify.vojtkov.dev/usr_unknown/terraform-provider-dokploy.git//modules/web-service"

  name           = "storefront"
  environment_id = dokploy_project.shop.default_environment_id

  service_source = {
    type       = "github"
    github_id  = var.github_provider_id
    owner      = "acme"
    repository = "storefront"
    branch     = "main"
  }

  replicas  = 2
  resources = { memory_limit = "1g", cpu_limit = "1" }

  domains = [
    { host = "shop.example.com", port = 3000 },
  ]

  mounts = [
    { mount_path = "/app/uploads", type = "volume", volume_name = "uploads" },
  ]
}

The variable is service_source rather than source because source is a reserved module meta-argument in Terraform.

See examples/complete for a full environment: project, staging environment, Postgres, Redis, two services and a Compose stack.

Deployments are not managed

This provider manages configuration, not rollouts. Creating or updating a dokploy_application writes its definition; it does not build or start anything. That is deliberate — a deployment is an imperative, time-bounded action with build logs and failure modes, not a converged state Terraform can own.

Trigger deployments from the Dokploy UI, from CI, or from the API:

curl -X POST "$DOKPLOY_HOST/api/application.deploy" \
  -H "x-api-key: $DOKPLOY_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"applicationId":"'"$APP_ID"'"}'

From CI

dokploy-deploy-action is the same call with the parts that matter in a pipeline: it waits for the build, fails the job when the deployment fails, and prints the build log. It runs on GitHub Actions and Gitea Actions.

- uses: https://gitea.coolify.vojtkov.dev/usr_unknown/dokploy-deploy-action@v1
  with:
    host: ${{ secrets.DOKPLOY_HOST }}
    api-key: ${{ secrets.DOKPLOY_API_KEY }}
    application-id: ${{ needs.infra.outputs.api_application_id }}
    docker-image: ghcr.io/acme/api:${{ github.sha }}

Feed it this provider's id output and the division of labour stays clean: Terraform owns the application's configuration, the action deploys code into it.

From Terraform itself

To couple the two, hang a terraform_data resource off the application so a configuration change triggers a redeploy:

resource "terraform_data" "deploy_api" {
  triggers_replace = [
    dokploy_application.api.docker_image,
    dokploy_application.api.env,
  ]

  provisioner "local-exec" {
    command = <<-CMD
      curl -fsS -X POST "$DOKPLOY_HOST/api/application.deploy" \
        -H "x-api-key: $DOKPLOY_API_KEY" \
        -H 'Content-Type: application/json' \
        -d '{"applicationId":"${dokploy_application.api.id}"}'
    CMD
  }
}

Most teams are better served letting Dokploy's own Git triggers (auto_deploy = true) handle rollouts and keeping Terraform to configuration.

Importing existing infrastructure

Every resource imports by its Dokploy ID:

terraform import dokploy_project.shop      xK2bZpaQQH4XDEmz9CfX1
terraform import dokploy_application.api   w_CJkBMFJTmkHZHVbXrJI
terraform import dokploy_postgres.db       H9o0itXlfPk8wxrRfIb8m

Find IDs through the API:

curl -H "x-api-key: $DOKPLOY_API_KEY" "$DOKPLOY_HOST/api/project.all" \
  | jq -r '.[] | "\(.name)\t\(.projectId)"'

dokploy_mount is the one exception: Dokploy stores the parent as a typed column (applicationId, composeId, …) and never echoes back the generic serviceId that creation uses, so set service_id and service_type in your configuration to match reality after importing.

Using this from Pulumi

Because this is a standard Terraform provider, Pulumi consumes it through pulumi-terraform-bridge — no reimplementation needed. Two routes:

Dynamic bridge (no build step). Point Pulumi at a provider binary and use it straight away. The usual owner/name shorthand resolves through the Terraform registry, which does not serve this provider, so give a path instead — make build produces one:

pulumi package add terraform-provider ./terraform-provider-dokploy

Pulumi generates an SDK for your language on the spot. This is the fastest path and keeps you in lockstep with the Terraform provider.

Static bridge (a published SDK). For a first-class, versioned package with richer types, use pulumi-dokploy, which wraps this provider and ships SDKs for TypeScript, Python, Go and .NET from this Gitea instance's package registries:

npm install @maxvojtkov/pulumi-dokploy
pip install pulumi_dokploy
go get github.com/maxvojtkov/pulumi-dokploy/sdk/go/dokploy
dotnet add package Maxvojtkov.Dokploy

Each of those needs its registry pointed at Gitea first — the one-time configuration per language is in that repository's Installing section.

That repository holds nothing but the mapping — every resource, schema and API call still comes from here, so the two providers move together.

Three things are worth knowing about the translation:

  • Attribute names become camelCase in Pulumi (environment_id → environmentId, default_environment_id → defaultEnvironmentId).
  • name is auto-generated when omitted, following Pulumi convention. Set it explicitly when the Dokploy-side name matters.
  • The web-service Terraform module has no automatic Pulumi equivalent — reimplement it as a ComponentResource, which is a natural fit for the same grouping. pulumi-dokploy ships a port of it in examples/typescript/webService.ts.

The shim package

internal/provider cannot be imported from another Go module, so the bridge goes through the small shim package instead:

import tfshim "github.com/maxvojtkov/terraform-provider-dokploy/shim"

p := tfshim.NewProvider("0.1.0") // a plugin-framework provider.Provider

Keep shim.NewProvider stable — it is this repository's only public Go API.

Volumes that actually persist

A dokploy_mount with type = "volume" must set volume_name:

resource "dokploy_mount" "data" {
  type         = "volume"
  volume_name  = "shop-uploads"   # required — see below
  mount_path   = "/app/uploads"
  service_type = "application"
  service_id   = dokploy_application.api.id
}

Dokploy turns a mount into a Docker mount with {Source: volumeName || "", Target: mountPath}. When volumeName is null the source is the empty string, and Docker reads an empty source as an anonymous volume — a new one on every single deploy, with the previous one left orphaned on disk. Nothing errors: the service starts, the path is writable, and the data is gone again after the next deployment while disk usage climbs.

Dokploy's API accepts that mount without complaint, so this provider rejects it at plan time instead:

Error: Missing volume_name

  with dokploy_mount.data,
  on main.tf line 1, in resource "dokploy_mount" "data":

`volume_name` must be set to a non-empty value when `type` is `volume`.

Dokploy passes an unset `volume_name` to Docker as an empty source, which
creates a new anonymous volume on every deploy. The data written to the
previous volume is orphaned and never reused, so the mount silently does not
persist anything.

The same check covers type = "bind" without host_path and type = "file" without file_path, and it rejects a field set against the wrong type — volume_name on a bind mount, say — which Dokploy would otherwise ignore.

If you already have such a mount, adding volume_name and redeploying gives you a persistent volume from that point on. The contents of the current anonymous volume are not migrated; copy the data off the host first if you need it.

Pair a named volume with dokploy_volume_backup to get it off the host on a schedule.

Known API quirks

These are properties of the Dokploy API that the provider works around; they explain behaviour that would otherwise look surprising.

  • create accepts only a subset of fields. For applications, Compose stacks, environments and all five databases, Dokploy's create endpoints ignore most fields. The provider creates the resource and immediately issues an update with the rest, so a single apply still converges.
  • Some endpoints return no ID. sshKey.create, redirects.create and security.create return nothing or true. The provider identifies the new record by diffing the relevant list before and after the call. If several are created outside Terraform at the same moment, this is ambiguous and the provider reports an error rather than guessing.
  • registry.create performs a real docker login. Invalid credentials fail the apply with the Docker error, by design.
  • Creating a registry, SSH key or certificate needs an organization. The provider resolves it once per run from user.get.
  • null is not universally accepted. Endpoints generated from the database schema accept null to clear a nullable column; hand-written ones (such as environment.create) reject it. The provider tracks this per field.
  • Basic auth passwords are never returned. dokploy_security.password keeps whatever you configured; drift in that one field cannot be detected. DNS and vault provider credentials behave the same way: Dokploy masks config on read, so dokploy_dns_provider.config and dokploy_vault_provider.config keep the configured value and are not checked for drift.
  • libsql.create is the strictest endpoint in the API. It requires eleven keys to be present — several only meaningfully null — declines to generate a service name the way every other engine does, and then returns true instead of the created row. The provider fills in the nulls, derives an app_name from name, and finds the new ID by diffing the environment's libSQL list, so dokploy_libsql behaves like the other databases.
  • A volume mount with no name is silently anonymous. Covered in Volumes that actually persist; the provider rejects it at plan time.
  • Docker networks cannot be updated. Dokploy exposes no network.update, matching Docker itself, so every attribute of dokploy_network forces replacement. Replacing a network detaches the services using it until they are redeployed.

Development

make build          # compile
make test           # unit tests
make testacc        # acceptance tests (creates and destroys real resources)
make docs           # regenerate docs/ from the provider schema
make fmt lint       # gofmt + go vet + terraform fmt
make install        # install into the local filesystem mirror

Acceptance tests talk to a live Dokploy instance and create resources prefixed tfacc-, destroying them afterwards. Point them at a scratch instance:

TF_ACC=1 \
DOKPLOY_HOST=https://dokploy.example.com \
DOKPLOY_API_KEY=... \
make testacc

They require the terraform binary; terraform-plugin-testing does not fully support OpenTofu's provider addressing. The provider itself works with both.

Layout

internal/client/      HTTP client for /api/<router>.<procedure>
internal/tfmap/       reflection-based mapping between models and Dokploy JSON
internal/provider/    provider, resources, data sources
modules/web-service/  reusable module
examples/complete/    worked example
docs/                 generated reference documentation

Resources are declared as ResourceSpec values — the procedures to call plus a model struct whose dokploy:"..." tags describe how each field is serialized — and a single generic implementation provides CRUD and import for all of them. Adding a resource means writing a model and a spec, not another CRUD loop.