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.
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
- Volumes that actually persist
- Known API quirks
- Development
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). 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.
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.
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. DNS and vault provider credentials behave the same way: Dokploy masksconfigon read, sodokploy_dns_provider.configanddokploy_vault_provider.configkeep the configured value and are not checked for drift. libsql.createis the strictest endpoint in the API. It requires eleven keys to be present — several only meaningfullynull— declines to generate a service name the way every other engine does, and then returnstrueinstead of the created row. The provider fills in the nulls, derives anapp_namefromname, and finds the new ID by diffing the environment's libSQL list, sodokploy_libsqlbehaves like the other databases.- A
volumemount 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 ofdokploy_networkforces 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.