Creating the service connection with Terraform¶
The Argo CD service connection can be managed as code, including the token inside it. Done properly, no human ever sees the credential: Terraform mints a project role token in Argo CD and writes it straight into the Azure DevOps connection.
Read this first¶
The Azure DevOps provider ships a resource called azuredevops_serviceendpoint_argocd. It does
not work with this extension.
It creates a connection of type argocd, which belongs to a different, older Marketplace
extension — its own documentation tells you to install that one. This extension's connection type
is argocdrest, because the argocd name was already claimed. Service endpoint type names
are global across the Marketplace and cannot be shared.
Use azuredevops_serviceendpoint_generic_v2 and set the type explicitly.
What you need¶
| Version | Why | |
|---|---|---|
microsoft/azuredevops |
>= 1.12.0 | azuredevops_serviceendpoint_generic_v2 was added in 1.12.0 |
argoproj-labs/argocd |
>= 7.0 | argocd_project_token — only needed if Terraform mints the token too |
| This extension | installed in the org | the connection type must exist before plan runs (see below) |
The full example¶
Three things in sequence: an AppProject with a role scoped to what the pipeline may do, a token bound to that role, and the Azure DevOps connection carrying it.
terraform {
required_providers {
azuredevops = { source = "microsoft/azuredevops", version = "~> 1.12" }
argocd = { source = "argoproj-labs/argocd", version = "~> 7.0" }
}
}
provider "argocd" {
server_addr = "argocd.example.com:443"
auth_token = var.argocd_admin_token # or username/password
}
provider "azuredevops" {
org_service_url = "https://dev.azure.com/my-org"
personal_access_token = var.azdo_pat
}
# 1. The project, with a role granting exactly what the pipeline needs.
resource "argocd_project" "payments" {
metadata {
name = "payments"
namespace = "argocd"
}
spec {
description = "Payments services"
source_repos = ["https://github.com/my-org/payments.git"]
destination {
server = "https://kubernetes.default.svc"
namespace = "payments"
}
role {
name = "ado-ci"
description = "Azure Pipelines deploy identity"
policies = [
"p, proj:payments:ado-ci, applications, get, payments/*, allow",
"p, proj:payments:ado-ci, applications, sync, payments/*, allow",
]
}
}
}
# 2. The token. Terraform regenerates it before it expires.
resource "argocd_project_token" "ado_ci" {
project = argocd_project.payments.metadata[0].name
role = "ado-ci"
description = "Azure Pipelines — managed by Terraform"
expires_in = "2160h" # 90 days
renew_before = "168h" # regenerate with 7 days to spare
lifecycle {
create_before_destroy = true
}
}
# 3. The Azure DevOps service connection.
resource "azuredevops_serviceendpoint_generic_v2" "argocd" {
project_id = azuredevops_project.payments.id
name = "argocd-prod"
description = "Managed by Terraform"
type = "argocdrest"
server_url = "https://argocd.example.com"
authorization_scheme = "Token"
authorization_parameters = {
apitoken = argocd_project_token.ado_ci.jwt
}
parameters = {
insecureSkipTlsVerify = "false"
grpcWebRootPath = ""
}
}
The pipeline then refers to it by name, exactly as if you had created it by hand:
- task: ArgoCDApp@1
displayName: Deploy payments
inputs:
connection: 'argocd-prod'
command: 'sync'
applications: 'payments-api'
project: 'payments'
Which field goes where¶
The two maps are not interchangeable, and putting a value in the wrong one produces a connection that saves without complaint and then fails at run time.
| Connection field | Terraform argument | Ends up in |
|---|---|---|
| API token | authorization_parameters.apitoken |
authorization.parameters |
| Server URL | server_url |
url |
| Custom CA certificate | parameters.caCertificate |
data |
| Skip TLS verification | parameters.insecureSkipTlsVerify |
data |
| Root path | parameters.grpcWebRootPath |
data |
Use authorization_scheme = "None" with no authorization_parameters for the anonymous variant.
Four things that catch people out¶
- The argument is
type, notservice_endpoint_type. The upstream example in the provider documentation uses the latter and does not work; the argument reference below that example is correct. Copy from here, not from there. parametersis a map of strings.insecureSkipTlsVerifymust be"false", quoted — not a bare boolean.- The extension must already be installed in the organisation.
generic_v2validates the type name, the auth scheme and every field id against the connection types the organisation actually has, at plan time. That catches typos early, but it does mean Terraform cannot install the extension and create the connection in the same run. - The token is in your state file, unencrypted.
authorization_parametersis marked sensitive so it stays out of plan output, andargocd_project_token.jwtlikewise — but both are plaintext in state, as all Terraform secrets are. Use an encrypted remote backend.
Rotation comes for free¶
Rotating the token by hand is three steps: mint, update the connection,
revoke the old one — in that order. The Terraform version collapses into one apply.
renew_before makes the token resource plan a replacement once its remaining lifetime drops below
the threshold. The new JWT flows into authorization_parameters in the same apply, so the
connection is updated in the same breath as the token is minted.
create_before_destroy in the example is doing real work. Terraform's default for a replacement is
destroy-then-create, which would revoke the working token before minting its successor — a window
where every pipeline fails. Creating first removes that window.
Run terraform apply on a schedule comfortably shorter than renew_before and the credential
rotates itself.
Without the Argo CD provider¶
If the token is minted elsewhere — by ArgoCDProject@1 in a rotation pipeline, or by hand — drop
the argocd provider and feed a variable in:
variable "argocd_token" {
type = string
sensitive = true
}
resource "azuredevops_serviceendpoint_generic_v2" "argocd" {
# ...
authorization_parameters = {
apitoken = var.argocd_token
}
}
Without Terraform¶
generic_v2 needs provider 1.12.0. If you are pinned below that, the connection is an ordinary
REST object:
{
"name": "argocd-prod",
"type": "argocdrest",
"url": "https://argocd.example.com",
"data": {
"insecureSkipTlsVerify": "false",
"grpcWebRootPath": ""
},
"authorization": {
"scheme": "Token",
"parameters": { "apitoken": "<argocd-project-role-token>" }
},
"isShared": false,
"serviceEndpointProjectReferences": [
{
"projectReference": { "id": "<project-guid>", "name": "MyProject" },
"name": "argocd-prod"
}
]
}
serviceEndpointProjectReferences is what attaches the connection to a project — the URL is
organisation-scoped, so without it the connection belongs nowhere.
The same document works with the Azure CLI, which passes it straight through:
az devops service-endpoint create \
--service-endpoint-configuration ./argocd-endpoint.json \
--org https://dev.azure.com/my-org --project MyProject \
--query id -o tsv
Constrain the output. The command echoes the whole endpoint object back on stdout, and in a pipeline that goes into the build log.
To confirm the type name and field ids against a live organisation: