Terraform Bootstrap

When provisioning a new cluster, it is possible to install Argo CD while provisioning the required token secrets for the upcoming Argo CD Gateway installation. Once Argo CD is installed, the Argo CD Gateway can be installed using an Argo CD Application as described in Automated Installation. Another approach would be to install the Argo CD Gateway as part of the terraform chart, as described under the Note.

Here is a simplified example to make this happen:

FilePurpose
providers.tfTerraform + kubernetes, helm, null, time providers
variables.tfAll inputs — kubeconfig, Argo CD URLs, Octopus credentials, gateway config
argocd.tfInstalls Argo CD via Helm; enables apiKey,login on the admin account
argocd-token.tfGenerates the Argo CD API key via the CLI and stores it in a k8s secret
gateway.tfCreates Octopus API key secret; optionally installs the gateway Helm chart
outputs.tfUseful one-liners and resource references
terraform.tfvars.exampleCopy → terraform.tfvars and fill in

Providers

# providers.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.27"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.13"
    }
    null = {
      source  = "hashicorp/null"
      version = "~> 3.2"
    }
    time = {
      source  = "hashicorp/time"
      version = "~> 0.11"
    }
  }
}

provider "kubernetes" {
  config_path    = var.kubeconfig_path
  config_context = var.kube_context
}

provider "helm" {
  kubernetes {
    config_path    = var.kubeconfig_path
    config_context = var.kube_context
  }
}

Variables

# variables.tf
# ─── Kubernetes ───────────────────────────────────────────────────────────────

variable "kubeconfig_path" {
  description = "Path to the kubeconfig file."
  type        = string
  default     = "~/.kube/config"
}

variable "kube_context" {
  description = "Kubernetes context to use. Defaults to the current context."
  type        = string
  default     = null
}

# ─── Argo CD ──────────────────────────────────────────────────────────────────

variable "argocd_namespace" {
  description = "Namespace to install Argo CD into."
  type        = string
  default     = "argocd"
}

variable "argocd_chart_version" {
  description = "Argo CD Helm chart version (from https://argoproj.github.io/argo-helm)."
  type        = string
  default     = "9.4.6"
}


variable "argocd_web_ui_url" {
  description = "Argo CD Web UI URL used for gateway registration (e.g. https://argocd.example.com)."
  type        = string
}

variable "argocd_insecure" {
  description = "Skip TLS verification on the gRPC connection from the gateway to Argo CD."
  type        = bool
  default     = false
}

# ─── Octopus Deploy ───────────────────────────────────────────────────────────

variable "octopus_api_url" {
  description = "Octopus Deploy HTTP API URL used for registration (e.g. https://my-instance.octopus.app)."
  type        = string
}

variable "octopus_grpc_url" {
  description = "Octopus Deploy gRPC URL including port (e.g. my-instance.octopus.app:443)."
  type        = string
}

variable "octopus_api_key" {
  description = "Octopus Deploy API key used to register the gateway."
  type        = string
  sensitive   = true
}

variable "octopus_space_id" {
  description = "Octopus Deploy Space ID the gateway registers into."
  type        = string
  default     = "Spaces-1"
}

variable "octopus_environments" {
  description = "List of Octopus Deploy environment slugs or IDs to associate with the gateway."
  type        = list(string)
  default     = []
}

variable "octopus_grpc_plaintext" {
  description = "Disable TLS on the Octopus gRPC connection. Only for development/local setups."
  type        = bool
  default     = false
}

# ─── Gateway ──────────────────────────────────────────────────────────────────

variable "gateway_namespace" {
  description = "Namespace to install the Octopus Argo CD Gateway into."
  type        = string
  default     = "octopus-argocd-gateway"
}

Argo CD

# argocd.tf
locals {
  # Derived from the Helm release name and namespace — no user input required.
  # The argo-cd chart names its server service as "<release-name>-server".
  argocd_grpc_url = "${helm_release.argocd.name}-server.${var.argocd_namespace}.svc.cluster.local:443"
}

resource "kubernetes_namespace" "argocd" {
  metadata {
    name = var.argocd_namespace
  }
}

# Install Argo CD via the official Helm chart.
# Creates a dedicated "octopus" service account with apiKey capability and the
# permissions required by Octopus Deploy (applications, clusters, logs).
# Admin retains login-only access so the bootstrap script can generate the octopus token.
resource "helm_release" "argocd" {
  name       = "argocd"
  repository = null
  chart      = "oci://ghcr.io/argoproj/argo-helm/argo-cd"
  version    = var.argocd_chart_version
  namespace  = kubernetes_namespace.argocd.metadata[0].name

  values = [
    yamlencode({
      configs = {
        cm = {
          # Dedicated service account for Octopus Deploy — API key only, no interactive login.
          "accounts.octopus" = "apiKey"
        }
        rbac = {
          "policy.default" = "role:readonly"
          "policy.csv"     = <<-EOT
            g, admin, role:admin
            p, octopus, applications, get, *, allow
            p, octopus, applications, sync, *, allow
            p, octopus, clusters, get, *, allow
            p, octopus, logs, get, */*, allow
          EOT
        }
      }
    })
  ]

  # Wait until all Argo CD pods are healthy before continuing.
  timeout = 600
  wait    = true
}

# Give the Argo CD server a moment to fully initialise its API
# (the rollout-status check alone isn't always sufficient).
resource "time_sleep" "wait_for_argocd" {
  depends_on      = [helm_release.argocd]
  create_duration = "30s"
}

Argo CD Token

# argocd-token.tf
locals {
  # Name of the Kubernetes secret that will hold the generated Argo CD token.
  # The secret is created in the gateway namespace so the gateway pod can mount it.
  argocd_token_secret_name = "argocd-gateway-token"
}

# Use a null_resource + local-exec to:
#   1. Wait for the Argo CD server deployment to be fully ready.
#   2. Port-forward the Argo CD server locally.
#   3. Log in with the argocd CLI using the auto-generated admin password.
#   4. Generate an API key for the octopus account.
#   5. Store that key in a Kubernetes secret in the gateway namespace.
#
# Prerequisites (must be available on the machine running `terraform apply`):
#   - kubectl  (configured to reach the target cluster)
#   - argocd   (https://argo-cd.readthedocs.io/en/stable/cli_installation/)
#   - nc / netcat
resource "null_resource" "argocd_token" {
  depends_on = [
    time_sleep.wait_for_argocd,
    kubernetes_namespace.gateway,
  ]

  # Re-run whenever Argo CD is reinstalled or the gateway namespace changes.
  triggers = {
    argocd_release_id = helm_release.argocd.id
    gateway_namespace = var.gateway_namespace
  }

  provisioner "local-exec" {
    interpreter = ["bash", "-c"]
    command     = <<-EOT
      set -euo pipefail

      echo ">>> Waiting for argocd-server deployment to be ready..."
      kubectl rollout status deployment/argocd-server \
        --namespace "${var.argocd_namespace}" \
        --timeout=300s

      echo ">>> Fetching initial admin password..."
      ARGOCD_PASSWORD=$(kubectl get secret argocd-initial-admin-secret \
        --namespace "${var.argocd_namespace}" \
        -o jsonpath='{.data.password}' | base64 --decode)

      echo ">>> Starting port-forward on localhost:18080 -> argocd-server:443..."
      # Use port 18080 to avoid conflicts with any local service on 8080.
      kubectl port-forward svc/argocd-server \
        --namespace "${var.argocd_namespace}" \
        18080:443 &
      PF_PID=$!
      trap 'echo ">>> Cleaning up port-forward (PID $PF_PID)"; kill "$PF_PID" 2>/dev/null || true' EXIT

      echo ">>> Waiting for port-forward to become available..."
      for i in $(seq 1 20); do
        if nc -z localhost 18080 2>/dev/null; then
          echo "    Ready after $i attempt(s)."
          break
        fi
        echo "    Attempt $i/20 — retrying in 3s..."
        sleep 3
      done

      echo ">>> Logging in to Argo CD..."
      argocd login localhost:18080 \
        --username admin \
        --password "$ARGOCD_PASSWORD" \
        --insecure \
        --grpc-web

      echo ">>> Generating API token for the octopus account..."
      ARGOCD_TOKEN=$(argocd account generate-token \
        --account octopus \
        --insecure \
        --grpc-web)

      echo ">>> Storing token in Kubernetes secret '${local.argocd_token_secret_name}' (namespace: ${var.gateway_namespace})..."
      kubectl create secret generic "${local.argocd_token_secret_name}" \
        --namespace "${var.gateway_namespace}" \
        --from-literal=ARGOCD_AUTH_TOKEN="$ARGOCD_TOKEN" \
        --dry-run=client -o yaml | kubectl apply -f -

      echo ">>> Done. Argo CD API token is ready."
    EOT
  }
}

Gateway

# gateway.tf
resource "kubernetes_namespace" "gateway" {
  metadata {
    name = var.gateway_namespace
  }
}

# Store the Octopus API key as a Kubernetes secret so it is never passed
# as a plain-text Helm value. The chart reads it via serverAccessTokenSecretName.
resource "kubernetes_secret" "octopus_api_key" {
  metadata {
    name      = "octopus-server-access-token"
    namespace = kubernetes_namespace.gateway.metadata[0].name
  }

  data = {
    OCTOPUS_SERVER_ACCESS_TOKEN = var.octopus_api_key
  }

  type = "Opaque"
}

Note In order to deploy the Argo CD Gateway using helm directly, you can re-use the helm provider:

# gateway.tf
resource "kubernetes_namespace" "gateway" {
  metadata {
    name = var.gateway_namespace
  }
}

# Store the Octopus API key as a Kubernetes secret so it is never passed
# as a plain-text Helm value. The chart reads it via serverAccessTokenSecretName.
resource "kubernetes_secret" "octopus_api_key" {
  metadata {
    name      = "octopus-server-access-token"
    namespace = kubernetes_namespace.gateway.metadata[0].name
  }

  data = {
    OCTOPUS_SERVER_ACCESS_TOKEN = var.octopus_api_key
  }

  type = "Opaque"
}

# Install the Octopus Argo CD Gateway.
# The chart is referenced from the published GitHub Pages Helm repository.
# Both the Argo CD token and the Octopus API key are supplied via existing
# Kubernetes secrets rather than inline values to avoid storing credentials
# in Terraform state or Helm release history.
resource "helm_release" "gateway" {
  name       = "octopus-argocd-gateway"
  repository = null
  chart      = "oci://registry-1.docker.io/octopusdeploy/octopus-argocd-gateway-chart"
  version    = var.gateway_chart_version
  namespace  = kubernetes_namespace.gateway.metadata[0].name

  depends_on = [
    # The Argo CD token secret must exist before the gateway pod starts.
    null_resource.argocd_token,
    kubernetes_secret.octopus_api_key,
  ]

  values = [
    yamlencode({
      gateway = {
        argocd = {
          # gRPC URL derived automatically from the Argo CD Helm release.
          serverGrpcUrl = local.argocd_grpc_url
          # Skip TLS verification if Argo CD is using a self-signed cert.
          insecure = var.argocd_insecure
          # Reference the secret created by null_resource.argocd_token.
          # The chart looks for the key ARGOCD_AUTH_TOKEN inside this secret.
          authenticationTokenSecretName = local.argocd_token_secret_name
          authenticationTokenSecretKey  = "ARGOCD_AUTH_TOKEN"
        }
        octopus = {
          serverGrpcUrl = var.octopus_grpc_url
          plaintext     = var.octopus_grpc_plaintext
        }
      }

      registration = {
        octopus = {
          name        = var.gateway_name
          serverApiUrl = var.octopus_api_url
          spaceId      = var.octopus_space_id
          environments = var.octopus_environments

          # Reference the Octopus API key secret created above.
          serverAccessTokenSecretName = kubernetes_secret.octopus_api_key.metadata[0].name
          serverAccessTokenSecretKey  = "OCTOPUS_SERVER_ACCESS_TOKEN"
        }
        argocd = {
          webUiUrl = var.argocd_web_ui_url
        }
      }
    })
  ]

  timeout = 300
  wait    = true
}

Outputs

# outputs.tf
output "argocd_namespace" {
  description = "Namespace where Argo CD is installed."
  value       = kubernetes_namespace.argocd.metadata[0].name
}

output "gateway_namespace" {
  description = "Namespace where the Octopus Argo CD Gateway is installed."
  value       = kubernetes_namespace.gateway.metadata[0].name
}

output "argocd_token_secret" {
  description = "Kubernetes secret (namespace/name) that holds the generated Argo CD API token."
  value       = "${var.gateway_namespace}/${local.argocd_token_secret_name}"
}

output "get_argocd_admin_password" {
  description = "One-liner to retrieve the Argo CD initial admin password."
  value       = "kubectl get secret argocd-initial-admin-secret -n ${var.argocd_namespace} -o jsonpath='{.data.password}' | base64 --decode && echo"
}

output "get_argocd_token" {
  description = "One-liner to view the stored Argo CD API token."
  value       = "kubectl get secret ${local.argocd_token_secret_name} -n ${var.gateway_namespace} -o jsonpath='{.data.ARGOCD_AUTH_TOKEN}' | base64 --decode && echo"
}

Terraform tfvars

# terraform.tfvars.example
# Copy this file to terraform.tfvars and fill in the values.
# Never commit terraform.tfvars to source control — it contains secrets.

# ─── Kubernetes ───────────────────────────────────────────────────────────────
kubeconfig_path = "~/.kube/config"
kube_context    = "my-cluster-context"   # omit to use the current context

# ─── Argo CD ──────────────────────────────────────────────────────────────────
argocd_namespace     = "argocd"
argocd_chart_version = "9.4.6"

# External Web UI URL — used during Octopus registration for the Argo CD link.
argocd_web_ui_url = "https://argocd.example.com"

# Set to true if Argo CD uses a self-signed certificate.
argocd_insecure = false

# ─── Octopus Deploy ───────────────────────────────────────────────────────────
octopus_api_url  = "https://my-instance.octopus.app"
octopus_grpc_url = "my-instance.octopus.app:8443"
octopus_api_key  = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"   # sensitive
octopus_space_id = "Spaces-1"

# List of environment slugs or IDs to associate with this gateway.
octopus_environments = ["production", "staging"]

# Set to true only when Octopus runs without TLS on its gRPC port (dev only).
octopus_grpc_plaintext = false

# ─── Gateway ──────────────────────────────────────────────────────────────────
gateway_namespace     = "octopus-argocd-gateway"

# only used if deploying the octopus-argocd-gateway using the helm-provider
gateway_name          = "my-argocd-gateway"
gateway_chart_version = "1.23.0"

Help us continuously improve

Please let us know if you have any feedback about this page.

Send feedback

Page updated on Thursday, March 26, 2026