A Terraform provider for Dokploy
Plugin-framework provider covering projects, environments, applications, Compose stacks, managed databases, domains, mounts, ports, redirects, basic auth, registries, SSH keys, certificates and backup destinations, over Dokploy's tRPC-over-REST API. The shim package exposes the provider to other Go modules, which is how pulumi-dokploy bridges it.
This commit is contained in:
440
README.md
Normal file
440
README.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# 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
|
||||
|
||||
The provider is not published to a registry yet. Build it and install it into a
|
||||
local filesystem mirror:
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 = "github.com/maxvojtkov/terraform-provider-dokploy//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://github.com/maxvojtkov/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: maxvojtkov/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 the provider binary and use
|
||||
it straight away:
|
||||
|
||||
```bash
|
||||
pulumi package add terraform-provider maxvojtkov/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://github.com/maxvojtkov/pulumi-dokploy), which wraps
|
||||
this provider and ships SDKs for TypeScript, Python, Go and .NET:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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/<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.
|
||||
Reference in New Issue
Block a user