consul-template in the GitLab CI deploy job
Published: 2026-05-22
consul-template is usually described as a daemon that watches Vault and rewrites config files in real time. We use it differently: as a one-shot renderer during helm upgrade. The pattern is: pull the chart from Artifactory, render secrets from Vault into the chart's config/ directory using HCL templates, then run helm upgrade so the rendered files end up in ConfigMaps and Secrets inside the cluster.
Why not ESO or Vault Agent?
For this project ESO wasn't available at the time we set this up. Vault Agent sidecar injects secrets at pod startup — fine for long-running services but adds a sidecar to every pod. The CI-side rendering approach keeps the cluster dumb: the Helm chart just consumes files from config/; no Vault dependency at runtime.
The tradeoff is that secrets are rendered into the chart during deploy and baked into ConfigMaps (for non-sensitive config) or Kubernetes Secrets (for credentials). If a Vault value changes, you need to re-run the pipeline to pick it up.
Chart layout
Every application chart has this structure:
my-service/
Chart.yaml
values.yaml
values-Development.yaml
values-Production.yaml
config/
appsettings.json # consul-template HCL — rendered at deploy time
appsettings.ENV.json # per-environment overrides
crt/
YC-CA.pem # rendered from Vault
files/
config.hcl # consul-template source template for appsettings.json
config-user.hcl # per-env overrides template
config-crt.hcl # CA certificate template
templates/
configmap.yaml
deployment.yaml
...
The config/ and crt/ files are outputs — they don't exist in git, they are created by consul-template -once during the CI job and then embedded into ConfigMaps by helm upgrade.
HCL templates
files/config.hcl is a consul-template file that reads from Vault KV v2 and renders valid JSON. The double-curly syntax is consul-template, not Go templates:
{{ with $env := env "APP_ENV" }}
{{ $prj := "my-service" }}
{
"ConnectionStrings": {
"DefaultConnection": "{{ $path := printf "Microservices/%s/%s/system" $prj $env }}{{ with secret $path }}{{ .Data.data.connection_string }}{{ end }}"
},
"RabbitMq": {
"Host": "{{ with secret (printf "common-secret/%s/" $env) }}{{ .Data.data.rabbit_hostname }}{{ end }}",
"Port": "{{ with secret (printf "common-secret/%s/" $env) }}{{ .Data.data.rabbit_port }}{{ end }}",
"Username": "{{ with secret (printf "common-secret/%s/" $env) }}{{ .Data.data.rabbit_username }}{{ end }}",
"Password": "{{ with secret (printf "common-secret/%s/" $env) }}{{ .Data.data.rabbit_password }}{{ end }}"
},
"Logging": {
"MinimumLevel": {
{{- if eq $env "Production" }}
"Default": "Warning"
{{- else }}
"Default": "Debug"
{{- end }}
}
}
}
{{ end }}
Two Vault path patterns in use:
Microservices/<service>/<env>/system— service-specific secrets (DB URL, API keys)common-secret/<env>/— shared infrastructure (RabbitMQ, Elastic, APM endpoint)
files/config-user.hcl dumps the entire user KV secret as pretty JSON:
{{ with $env := env "APP_ENV" }}
{{ $prj := "my-service" }}
{{ $path := printf "Microservices/%s/%s/user" $prj $env }}{{ with secret $path }}{{ .Data.data | toJSONPretty }}{{ end }}
{{ end }}
files/config-crt.hcl pulls the YC root CA from the shared Vault path:
{{ with $env := env "APP_ENV" }}
{{ with secret (printf "common-secret/%s/" $env) }}{{ .Data.data.yc_ca }}{{ end }}
{{ end }}
Deploy job
yaml.deploy:
image: registry.example.com/ci-images/vault-cli:1.0.1
stage: deploy
script:
# 1. Pull chart from Helm registry
- helm repo add charts https://artifacts.example.com/repository/helm/
- helm repo update
- helm fetch charts/${CHART_NAME} --untar
# 2. Authenticate to Vault via GitLab JWT
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login
role=access-${APP_ENV} jwt=$CI_JOB_JWT)"
- vault kv get -field=kubeconfig_${NAMESPACE} common-secret/${APP_ENV} > kube_config
- chmod 600 kube_config
# 3. Render secrets into the chart directory
- consul-template -template="${CHART_NAME}/files/config-crt.hcl:${CHART_NAME}/crt/YC-CA.pem" -once
- consul-template -template="${CHART_NAME}/files/config-user.hcl:${CHART_NAME}/config/appsettings.ENV.json" -once
- consul-template -template="${CHART_NAME}/files/config.hcl:${CHART_NAME}/config/appsettings.json" -once
# 4. Deploy with rendered files now present in chart/config/
- helm upgrade --install --wait \
--kubeconfig kube_config \
${INSTANCE} ${CHART_NAME} \
-f ${CHART_NAME}/values-${APP_ENV}.yaml \
-n ${NAMESPACE} \
--set aspnetcoreEnvironment=${APP_ENV} \
--set image.tag=${CI_COMMIT_SHORT_SHA}
The -template src:dst flag of consul-template -once takes the HCL source and writes the rendered output to the destination path. The Vault token is read from $VAULT_TOKEN env var automatically.
How the chart consumes the rendered files
In templates/configmap.yaml the chart reads the rendered config/appsettings.json using Files.Get:
yamlapiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "my-service.fullname" . }}-app-settings
data:
appsettings.json: |
{{ tpl (.Files.Get "config/appsettings.json") . | indent 4 }}
appsettings.{{ .Values.aspnetcoreEnvironment }}.json: |
{{ tpl (.Files.Get "config/appsettings.ENV.json") . | indent 4 }}
The CA cert ends up in a separate ConfigMap mounted at /etc/ssl/certs/:
yamlapiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "my-service.fullname" . }}-ca-pemstore
data:
YC-CA.pem: |
{{ .Files.Get "crt/YC-CA.pem" | indent 4 }}
Rolling update on config change
The Deployment template includes a checksum annotation so pods restart whenever the ConfigMap content changes:
yamlspec:
template:
metadata:
annotations:
checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/configmap-ca: {{ include (print $.Template.BasePath "/capem-configmap.yaml") . | sha256sum }}
Helm computes sha256sum of the rendered ConfigMap content. If the content changes between two helm upgrade calls, the Pod template hash changes and Kubernetes triggers a rolling restart.
Debugging render failures
bash# Test consul-template rendering locally
export VAULT_ADDR=https://vault.test.example.com
export VAULT_TOKEN=<your-dev-token>
export APP_ENV=Development
consul-template -template="files/config.hcl:/tmp/appsettings.json" -once -log-level=debug
cat /tmp/appsettings.json
If consul-template hangs (doesn't exit with -once), it means a secret path doesn't exist — the {{ with secret }} block blocks until it gets data. Check the Vault path exists:
bashvault kv get Microservices/my-service/Development/system
Limitations
- If a Vault secret changes without a new deploy, pods keep the old value until the next pipeline run. For frequently rotating credentials, pair this with External Secrets Operator or Vault Agent instead.
- The rendered
config/appsettings.jsonis never committed to git — it exists only in the ephemeral CI runner filesystem during the deploy job. consul-template -onceexits non-zero if Vault is unreachable or the token lacks permission. This is desirable: the helm upgrade never runs with incomplete config.
Summary
consul-template -oncerenders Vault secrets into chart files during CI deploy — no Vault dependency at runtime- Rendered files live only in the ephemeral CI runner filesystem; they are never committed to git
checksum/configmapannotation on the Deployment triggers rolling restart whenever config changes- If Vault is unreachable or the token lacks permissions,
consul-template -onceexits non-zero andhelm upgradenever runs - For frequently rotating credentials, consider External Secrets Operator or Vault Agent instead