# terraform-provider-dokploy Manage [Dokploy](https://dokploy.com) 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](#using-this-from-pulumi) without rewriting anything. ```hcl 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](#installing) - [Configuring](#configuring) - [How Dokploy's model maps to Terraform](#how-dokploys-model-maps-to-terraform) - [Resources and data sources](#resources-and-data-sources) - [The `web-service` module](#the-web-service-module) - [Deployments are not managed](#deployments-are-not-managed) - [Importing existing infrastructure](#importing-existing-infrastructure) - [Using this from Pulumi](#using-this-from-pulumi) - [Volumes that actually persist](#volumes-that-actually-persist) - [Known API quirks](#known-api-quirks) - [Development](#development) ## Installing This provider is distributed through [Gitea releases](https://gitea.coolify.vojtkov.dev/usr_unknown/terraform-provider-dokploy/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](https://docs.gitea.com/usage/packages/terraform) 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 ```bash 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 ```bash make install ``` That writes the binary to `~/.local/share/terraform/plugins/registry.terraform.io/maxvojtkov/dokploy/0.2.0/_/` and prints the CLI configuration to add to `~/.terraformrc`: ```hcl 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: ```hcl terraform { required_providers { dokploy = { source = "maxvojtkov/dokploy" version = "0.2.0" } } } ``` ## Configuring Generate an API token in Dokploy under **Settings → Profile → API/CLI**. ```hcl 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` | ```bash 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`: ```hcl 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/`](./docs). ### Resources | Resource | Purpose | | -------------------------------------------------------- | ------------------------------------------------ | | [`dokploy_project`](docs/resources/project.md) | Top-level container | | [`dokploy_environment`](docs/resources/environment.md) | Additional environments within a project | | [`dokploy_application`](docs/resources/application.md) | A service built from Git, a Docker image or an upload | | [`dokploy_compose`](docs/resources/compose.md) | A Docker Compose or Swarm stack | | [`dokploy_postgres`](docs/resources/postgres.md) | Managed PostgreSQL | | [`dokploy_mysql`](docs/resources/mysql.md) | Managed MySQL | | [`dokploy_mariadb`](docs/resources/mariadb.md) | Managed MariaDB | | [`dokploy_mongo`](docs/resources/mongo.md) | Managed MongoDB | | [`dokploy_redis`](docs/resources/redis.md) | Managed Redis | | [`dokploy_libsql`](docs/resources/libsql.md) | Managed libSQL (`sqld`) | | [`dokploy_domain`](docs/resources/domain.md) | A hostname routed through Traefik | | [`dokploy_mount`](docs/resources/mount.md) | Volume, bind mount or config file | | [`dokploy_port`](docs/resources/port.md) | A port published straight onto the host | | [`dokploy_redirect`](docs/resources/redirect.md) | A Traefik redirect rule | | [`dokploy_security`](docs/resources/security.md) | HTTP basic auth credentials | | [`dokploy_registry`](docs/resources/registry.md) | A container registry | | [`dokploy_ssh_key`](docs/resources/ssh_key.md) | SSH key pair for private Git and remote servers | | [`dokploy_certificate`](docs/resources/certificate.md) | An uploaded TLS certificate | | [`dokploy_destination`](docs/resources/destination.md) | S3-compatible backup destination | | [`dokploy_network`](docs/resources/network.md) | A Docker network services attach to | | [`dokploy_schedule`](docs/resources/schedule.md) | A cron job run in a container or on a server | | [`dokploy_volume_backup`](docs/resources/volume_backup.md) | A scheduled backup of a Docker volume | | [`dokploy_dns_provider`](docs/resources/dns_provider.md) | Cloudflare or Route53, for automatic DNS records | | [`dokploy_vault_provider`](docs/resources/vault_provider.md) | An external secret manager for deploy-time env | ### Data sources | Data source | Purpose | | ------------------------------------------------------------ | ---------------------------------------- | | [`dokploy_project`](docs/data-sources/project.md) | Look up a project and its environments | | [`dokploy_projects`](docs/data-sources/projects.md) | List every visible project | | [`dokploy_environment`](docs/data-sources/environment.md) | Look up a single environment | | [`dokploy_application`](docs/data-sources/application.md) | Look up a single application | | [`dokploy_servers`](docs/data-sources/servers.md) | List registered remote servers | ## The `web-service` module [`modules/web-service`](./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: ```hcl 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`](./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: ```bash 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`](https://gitea.coolify.vojtkov.dev/usr_unknown/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. ```yaml - 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: ```hcl 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: ```bash terraform import dokploy_project.shop xK2bZpaQQH4XDEmz9CfX1 terraform import dokploy_application.api w_CJkBMFJTmkHZHVbXrJI terraform import dokploy_postgres.db H9o0itXlfPk8wxrRfIb8m ``` Find IDs through the API: ```bash 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`](https://github.com/pulumi/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: ```bash 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`](https://gitea.coolify.vojtkov.dev/usr_unknown/pulumi-dokploy), which wraps this provider and ships SDKs for TypeScript, Python, Go and .NET from this Gitea instance's package registries: ```bash 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](https://gitea.coolify.vojtkov.dev/usr_unknown/pulumi-dokploy#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](https://www.pulumi.com/docs/concepts/resources/components/), 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: ```go 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`: ```hcl 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`](docs/resources/volume_backup.md) 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](#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 ```bash 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: ```bash 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/. 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.