# 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) - [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), not a Terraform registry — Gitea implements about twenty package registries, and the Terraform provider protocol is not one of them. So installation goes through a **filesystem mirror**, which is Terraform's supported way to use a provider that no registry serves. ### From a release ```bash VERSION=0.1.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.1.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.1.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_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 | ### 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. ## 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. ## 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.