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.
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
- Configuring
- How Dokploy's model maps to Terraform
- Resources and data sources
- The
web-servicemodule - Deployments are not managed
- Importing existing infrastructure
- Using this from Pulumi
- Known API quirks
- Development
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_id→environmentId,default_environment_id→defaultEnvironmentId). nameis auto-generated when omitted, following Pulumi convention. Set it explicitly when the Dokploy-side name matters.- The
web-serviceTerraform module has no automatic Pulumi equivalent — reimplement it as a ComponentResource, which is a natural fit for the same grouping.pulumi-dokployships a port of it inexamples/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.
createaccepts 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 singleapplystill converges.- Some endpoints return no ID.
sshKey.create,redirects.createandsecurity.createreturn nothing ortrue. 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.createperforms a realdocker 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. nullis not universally accepted. Endpoints generated from the database schema acceptnullto clear a nullable column; hand-written ones (such asenvironment.create) reject it. The provider tracks this per field.- Basic auth passwords are never returned.
dokploy_security.passwordkeeps 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.