Files
terraform-provider-dokploy/README.md
Max Vojtkov abe4444d2b Publish from Gitea: releases, CI and install docs
Gitea implements no Terraform provider registry, so distribution goes
through release archives plus a filesystem mirror. GoReleaser targets
the Gitea release API; the archive names follow HashiCorp's convention
because that is the only shape a filesystem mirror recognises.

No GPG signing: signatures are a registry-protocol requirement and a
filesystem mirror never checks them.

Cross-repo links point at Gitea; Go module paths deliberately do not.
2026-08-09 12:37:07 +03:00

18 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, 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

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

make install

That writes the binary to ~/.local/share/terraform/plugins/registry.terraform.io/maxvojtkov/dokploy/0.1.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.1.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_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

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_idenvironmentId, default_environment_iddefaultEnvironmentId).
  • 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.

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

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.