YAML anchors and aliases let you define a value once and reuse it throughout your document, following the DRY (Don't Repeat Yourself) principle. Combined with merge keys (<<), you can build composable, maintainable configuration files for Docker Compose, GitHub Actions, GitLab CI, Kubernetes, and more. This guide covers everything from basic syntax to advanced patterns and common pitfalls.
1. What Are Anchors & Aliases?
In YAML, an anchor is a marker you attach to a node (scalar, mapping, or sequence) using the & character followed by a name. An alias is a reference to that anchor using the * character followed by the same name. When the YAML parser encounters an alias, it substitutes the anchored value in place.
This mechanism is part of the YAML 1.1 and 1.2 specifications, meaning it works with any compliant parser including PyYAML, js-yaml, SnakeYAML, ruamel.yaml, and go-yaml.
Anchors and aliases are the native YAML way to avoid repeating yourself. Instead of copying the same block of configuration multiple times, you define it once and reference it everywhere else.
# Anchor: &name attaches a label to a value
# Alias: *name references that labeled value
defaults: &default_settings
timeout: 30
retries: 3
verbose: false
# Alias: reuse the entire defaults block
production:
<<: *default_settings
verbose: true
# After parsing, production = { timeout: 30, retries: 3, verbose: true }- &name creates an anchor on a node
- *name creates an alias (reference) to that anchor
- Anchors must be defined before they are referenced
- Anchor names can contain alphanumeric characters, hyphens, and underscores
- Aliases produce an identical copy of the anchored node
2. Basic Anchor & Alias Syntax
The simplest use of anchors and aliases is with scalar values (strings, numbers, booleans). You attach an anchor to a value and then reference it elsewhere in the same document.
Scalar anchors:
# Define a scalar value with an anchor
db_host: &db_host "postgres.example.com"
db_port: &db_port 5432
db_name: &db_name "myapp_production"
# Reference with aliases
connection_string: "postgresql://user:pass@*db_host:*db_port/*db_name"
# Note: aliases work as standalone values, not inside strings!
# Correct usage:
primary:
host: *db_host
port: *db_port
name: *db_name
backup:
host: *db_host # Same host as primary
port: *db_port # Same port as primary
name: *db_name # Same database nameAfter parsing, db_host in both connection_string and backup_host resolves to "postgres.example.com".
Multiple anchors in the same document:
# Multiple anchors for different values
app_version: &version "2.5.0"
node_image: &node_img "node:20-alpine"
python_image: &python_img "python:3.12-slim"
default_replicas: &replicas 3
services:
api:
image: *node_img
replicas: *replicas
labels:
version: *version
worker:
image: *python_img
replicas: *replicas
labels:
version: *version
frontend:
image: *node_img
replicas: 1 # Override: only 1 replica for frontend
labels:
version: *version3. Anchoring Objects (Mappings)
The real power of anchors comes from anchoring entire mappings (objects). You can define a complete configuration block once and reuse it across your document.
When you anchor a mapping, the alias produces a complete copy of every key-value pair in that mapping.
# Anchor an entire mapping (object)
logging: &default_logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
tag: "{{.Name}}"
services:
api_server:
image: myapp-api:latest
logging: *default_logging # Entire logging config reused
worker_server:
image: myapp-worker:latest
logging: *default_logging # Same logging config
scheduler:
image: myapp-scheduler:latest
logging: *default_logging # Same logging configAnchoring nested objects:
# Anchor nested configuration blocks
database_config: &db_config
host: db.internal.example.com
port: 5432
pool_size: 20
ssl: true
timeout: 30
cache_config: &cache_config
host: redis.internal.example.com
port: 6379
ttl: 3600
environments:
production:
database: *db_config
cache: *cache_config
staging:
database: *db_config # Exact same DB config
cache: *cache_config # Exact same cache config
# For different config, you'd need merge keys (section 4)
# or define a new block4. Merge Key (<<): Merging Mappings
The merge key (<<) is a YAML extension that lets you merge an anchored mapping into another mapping while allowing you to override specific fields. This is far more useful than plain aliases for configuration files.
With <<: *alias, all keys from the anchored mapping are inserted into the current mapping. If the current mapping already defines a key that exists in the anchor, the local value wins (local takes precedence).
# Define defaults with an anchor
defaults: &service_defaults
image: myapp:latest
restart: always
environment:
NODE_ENV: production
LOG_LEVEL: info
volumes:
- /var/log/app:/app/logs
ports:
- "8080:3000"
services:
production:
<<: *service_defaults # Merge all defaults
# production uses everything as-is
staging:
<<: *service_defaults # Merge all defaults
ports:
- "8081:3000" # Override: different port
environment:
NODE_ENV: staging # Override: different NODE_ENV
LOG_LEVEL: debug # Override: more verbose logging
development:
<<: *service_defaults # Merge all defaults
image: myapp:dev # Override: dev image
ports:
- "3000:3000" # Override: direct port mapping
environment:
NODE_ENV: development
LOG_LEVEL: debug
DEBUG: "true"Override precedence:
Local keys always take precedence over merged keys. This is the fundamental rule of merge keys.
# Override precedence demonstration
base: &base
name: "default"
timeout: 30
retries: 3
debug: false
service:
<<: *base
name: "my-service" # Overrides "default" -> "my-service"
debug: true # Overrides false -> true
# timeout: 30 <- inherited from base (not overridden)
# retries: 3 <- inherited from base (not overridden)
extra_key: "new" # Added: not in base at all
# Parsed result:
# service:
# name: "my-service"
# timeout: 30
# retries: 3
# debug: true
# extra_key: "new"5. Multiple Merges & Precedence
You can merge from multiple anchors at once by passing a list to the << key. When merging multiple anchors, the first anchor in the list has the highest precedence among the merged values, and local keys still override everything.
Precedence order (highest to lowest):
- 1. Local keys defined in the current mapping
- 2. First anchor in the merge list
- 3. Second anchor in the merge list
- 4. Third anchor in the merge list, and so on
# Multiple merge sources
app_defaults: &app_defaults
image: myapp:latest
restart: always
replicas: 3
logging_defaults: &logging_defaults
logging:
driver: json-file
options:
max-size: "10m"
monitoring_defaults: &monitoring_defaults
labels:
monitoring: "true"
team: "platform"
healthcheck:
interval: 30s
timeout: 10s
retries: 3
services:
api:
# Merge from multiple anchors (list syntax)
<<: [*app_defaults, *logging_defaults, *monitoring_defaults]
ports:
- "8080:3000"
worker:
<<: [*app_defaults, *logging_defaults, *monitoring_defaults]
replicas: 5 # Override: more replicas for worker
command: ["npm", "run", "worker"]
# If app_defaults and monitoring_defaults both define "labels",
# app_defaults wins (first in the list)# Precedence example with conflicting keys
first: &first
color: red
size: large
weight: heavy
second: &second
color: blue
size: medium
shape: round
result:
<<: [*first, *second]
color: green # Local override
# Parsed result:
# result:
# color: green <- local key wins
# size: large <- from *first (first in list)
# weight: heavy <- from *first (only source)
# shape: round <- from *second (only source)6. Anchoring Arrays (Sequences)
You can anchor entire arrays (sequences) just like scalars and mappings. However, there is an important limitation: you cannot merge arrays the way you merge mappings with <<.
Array anchors work with simple aliasing:
# Anchor an entire array
shared_volumes: &volumes
- ./config:/app/config:ro
- ./logs:/app/logs
- /var/run/docker.sock:/var/run/docker.sock
shared_ports: &ports
- "8080:3000"
- "8443:3443"
services:
web:
volumes: *volumes # Reuse entire volume list
ports: *ports # Reuse entire port list
api:
volumes: *volumes # Same volumes
ports:
- "9090:3000" # Different ports (cannot merge with *ports)Workaround for extending arrays:
# YAML does NOT support this (will cause an error):
# combined:
# <<: [*list_a, *list_b] # ERROR: << only works with mappings
# Workaround 1: Repeat values manually
all_hosts:
- host1.example.com
- host2.example.com
- host3.example.com # Additional host
- host4.example.com # Additional host
# Workaround 2: Use a mapping with anchor + merge instead
host_group_a: &hosts_a
host1: host1.example.com
host2: host2.example.com
host_group_b: &hosts_b
host3: host3.example.com
host4: host4.example.com
all_hosts:
<<: [*hosts_a, *hosts_b]
# Result: { host1: ..., host2: ..., host3: ..., host4: ... }7. Docker Compose: x- Extension Fields
Docker Compose is the most popular real-world use case for YAML anchors. Starting with Compose file format 3.4+, you can use x- prefixed top-level keys as extension fields to hold your anchor definitions. Docker Compose ignores any top-level key starting with x-.
This pattern is the recommended way to share configuration across services:
# docker-compose.yml
# x- extension fields are ignored by Docker Compose
x-default-service: &default-service
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- app-network
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
services:
api:
<<: *default-service
image: myapp-api:latest
ports:
- "8080:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://db:5432/myapp
depends_on:
- postgres
- redis
worker:
<<: *default-service
image: myapp-worker:latest
environment:
- NODE_ENV=production
- QUEUE_URL=redis://redis:6379
depends_on:
- redis
scheduler:
<<: *default-service
image: myapp-scheduler:latest
environment:
- NODE_ENV=production
deploy:
resources:
limits:
memory: 256M # Less memory for scheduler
reservations:
memory: 128M
postgres:
<<: *default-service
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=app
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
redis:
<<: *default-service
image: redis:7-alpine
volumes:
- redisdata:/data
networks:
app-network:
driver: bridge
volumes:
pgdata:
redisdata:Advanced Docker Compose with multiple anchors:
# Advanced: Multiple x- extension fields for composability
x-logging: &logging
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
x-healthcheck-http: &healthcheck-http
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
x-healthcheck-tcp: &healthcheck-tcp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app || exit 1"]
interval: 10s
timeout: 5s
retries: 5
x-deploy-standard: &deploy-standard
deploy:
replicas: 2
resources:
limits:
cpus: "1.0"
memory: 512M
services:
api:
<<: [*logging, *healthcheck-http, *deploy-standard]
image: myapp-api:latest
ports:
- "8080:3000"
worker:
<<: [*logging, *deploy-standard]
image: myapp-worker:latest
deploy:
replicas: 4 # More workers needed
postgres:
<<: [*logging, *healthcheck-tcp]
image: postgres:16-alpine8. GitHub Actions: Reusable Steps with Anchors
GitHub Actions YAML workflows support anchors and aliases, though with some caveats. Anchors work within a single workflow file but not across files.
Common patterns include reusing environment variables, step configurations, and matrix definitions:
# .github/workflows/ci.yml
name: CI Pipeline
# Define reusable environment variables
env: &shared-env
NODE_VERSION: "20"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
env: *shared-env
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- &install-deps
name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test -- --coverage
build:
runs-on: ubuntu-latest
needs: lint-and-test
env: *shared-env
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- *install-deps # Reuse the install step
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/9. GitLab CI: Template Jobs with Anchors
GitLab CI has first-class support for hidden jobs (prefixed with a dot) that serve as templates. You can use YAML anchors or GitLab's extends keyword to achieve similar results.
Using anchors vs extends:
Using YAML anchors:
# .gitlab-ci.yml β Using YAML anchors
stages:
- test
- build
- deploy
# Hidden job as anchor template (dot prefix = hidden)
.default_job: &default_job
image: node:20-alpine
before_script:
- npm ci
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
tags:
- docker
.deploy_template: &deploy_template
image: alpine:latest
before_script:
- apk add --no-cache curl
when: manual
tags:
- docker
# Jobs using anchors
test:
<<: *default_job
stage: test
script:
- npm run lint
- npm test -- --coverage
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
build:
<<: *default_job
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 week
deploy_staging:
<<: *deploy_template
stage: deploy
script:
- curl -X POST "https://api.example.com/deploy?env=staging"
environment:
name: staging
url: https://staging.example.com
deploy_production:
<<: *deploy_template
stage: deploy
script:
- curl -X POST "https://api.example.com/deploy?env=production"
environment:
name: production
url: https://example.com
only:
- mainUsing GitLab extends (preferred):
# .gitlab-ci.yml β Using GitLab extends (preferred)
stages:
- test
- build
- deploy
# Hidden template jobs (no anchors needed)
.default_job:
image: node:20-alpine
before_script:
- npm ci
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
tags:
- docker
.deploy_template:
image: alpine:latest
before_script:
- apk add --no-cache curl
when: manual
tags:
- docker
# Jobs using extends (deep merge!)
test:
extends: .default_job
stage: test
script:
- npm run lint
- npm test
build:
extends: .default_job
stage: build
script:
- npm run build
deploy_staging:
extends: .deploy_template
stage: deploy
script:
- curl -X POST "https://api.example.com/deploy?env=staging"
environment:
name: stagingComparison:
| Feature | YAML Anchors | GitLab extends |
|---|---|---|
| Merge type | Shallow | Deep |
| Readability | Moderate | High |
| Cross-file | No | Yes (include) |
| Standard YAML | Yes | No (GitLab-specific) |
10. Kubernetes: Common Labels & Resource Limits
Kubernetes YAML manifests often have repetitive metadata, labels, resource limits, and environment variables. While Kubernetes does not process anchors natively (kubectl applies the resolved YAML), you can use anchors in your source files and let the YAML parser resolve them before applying.
Common patterns for Kubernetes:
# kubernetes-manifests.yaml
# Common definitions (using YAML multi-document with ---)
# Shared labels and metadata
_anchors:
labels: &common-labels
app.kubernetes.io/part-of: myapp
app.kubernetes.io/managed-by: kubectl
team: backend
environment: production
resources: &default-resources
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env: &common-env
- name: LOG_LEVEL
value: "info"
- name: TZ
value: "UTC"
- name: OTEL_EXPORTER_ENDPOINT
value: "http://otel-collector:4317"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
labels:
<<: *common-labels
app.kubernetes.io/name: api-server
app.kubernetes.io/component: api
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: api-server
template:
metadata:
labels:
<<: *common-labels
app.kubernetes.io/name: api-server
spec:
containers:
- name: api
image: myapp-api:v2.5.0
resources: *default-resources
env:
*common-env
ports:
- containerPort: 3000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
labels:
<<: *common-labels
app.kubernetes.io/name: worker
app.kubernetes.io/component: worker
spec:
replicas: 5
selector:
matchLabels:
app.kubernetes.io/name: worker
template:
metadata:
labels:
<<: *common-labels
app.kubernetes.io/name: worker
spec:
containers:
- name: worker
image: myapp-worker:v2.5.0
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi # Worker needs more memory
env:
*common-env11. Limitations & Gotchas
While anchors and aliases are powerful, they have several important limitations you should be aware of:
No cross-file anchors
Anchors are scoped to a single YAML document (within a single file, or between --- document separators). You cannot reference an anchor defined in a different file.
# file-a.yaml
database: &db_config
host: localhost
port: 5432
# file-b.yaml
service:
db: *db_config # ERROR: *db_config is not defined in this file!
# Solution: Keep all anchors and aliases in the same file
# Or use tool-specific features (GitLab include, Helm, etc.)No JSON compatibility
JSON does not support anchors or aliases. If your YAML is converted to JSON (e.g., for an API), anchors are resolved to their values during parsing. The $ref mechanism in JSON Schema is a different feature entirely.
# YAML with anchors:
defaults: &defaults
timeout: 30
retries: 3
service:
<<: *defaults
name: api
# Converts to JSON as (anchors resolved):
# {
# "defaults": { "timeout": 30, "retries": 3 },
# "service": { "timeout": 30, "retries": 3, "name": "api" }
# }
# No anchor/alias information is preserved in JSONYAML bombs (billion laughs)
Recursive or deeply nested anchors can create exponentially large data structures, similar to XML billion laughs attacks. This is a known security concern:
# YAML bomb / Billion laughs attack
# WARNING: Do NOT parse this with unlimited settings!
a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
# Each level multiplies by 9: 9^5 = 59,049 "lol" strings
# More levels = exponential growth = memory exhaustion# Safe parsing examples:
# Python (PyYAML) β use SafeLoader
import yaml
with open('config.yaml') as f:
data = yaml.safe_load(f) # SafeLoader prevents code execution
# JavaScript (js-yaml) β default is safe
const yaml = require('js-yaml');
const data = yaml.load(fs.readFileSync('config.yaml', 'utf8'));
// js-yaml has built-in maxAliasCount (default: 100)
# Go (go-yaml) β set limits
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true) // Reject unknown fieldsCannot partially modify aliases
An alias (*name) produces an exact copy. You cannot modify individual fields of an aliased mapping without using the merge key (<<). And even with <<, you can only override top-level keys, not nested keys.
# Cannot partially modify an alias
defaults: &defaults
database:
host: localhost
port: 5432
pool_size: 10
# This REPLACES the entire database mapping, not just pool_size:
staging:
<<: *defaults
database:
pool_size: 5
# host and port are LOST! << only merges top-level keys.
# Result (NOT what you might expect):
# staging:
# database:
# pool_size: 5 # host and port are gone!
# Solution: Anchor at a finer granularity
db_host: &db_host "localhost"
db_port: &db_port 5432
staging:
database:
host: *db_host
port: *db_port
pool_size: 5 # Only pool_size is differentParser support varies
The merge key (<<) is defined in the YAML 1.1 specification but was removed from YAML 1.2. However, most parsers still support it for backward compatibility. Check your parser documentation.
Circular references
YAML allows circular references in theory, but most parsers reject them or set recursion limits. Avoid creating anchors that reference themselves.
12. Alternatives to Anchors & Aliases
When YAML anchors are not sufficient for your needs, consider these alternatives:
YAML includes (non-standard)
Some tools support custom !include tags to reference external files. This is not part of the YAML specification but is implemented by tools like Home Assistant, Ansible, and custom YAML loaders.
# Non-standard !include (supported by some tools)
# config.yaml
database: !include database.yaml
logging: !include logging.yaml
# database.yaml
host: localhost
port: 5432
name: myappJSON $ref
JSON Schema and OpenAPI use $ref for cross-document references. This is a separate mechanism from YAML anchors and works across files.
# OpenAPI / JSON Schema $ref
paths:
/users:
get:
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/UserList'
400:
$ref: '#/components/responses/BadRequest'Helm templates
For Kubernetes, Helm provides Go templating with values.yaml, named templates, helpers, and conditional logic. Much more powerful than anchors for complex deployments.
# Helm template example (templates/deployment.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}Jsonnet
A data templating language that compiles to JSON. Supports variables, functions, conditionals, imports, and more. Used by Grafana, Tanka, and other tools.
// Jsonnet example
local defaults = {
replicas: 3,
image: 'myapp:latest',
resources: {
limits: { cpu: '500m', memory: '512Mi' },
},
};
{
api: defaults + {
name: 'api-server',
ports: [{ containerPort: 3000 }],
},
worker: defaults + {
name: 'worker',
replicas: 5, // Override
},
}Kustomize
A Kubernetes-native configuration management tool that supports overlays, patches, and cross-file transformations without templates.
Dhall
A programmable configuration language with a type system, imports, and functions. Compiles to YAML, JSON, or other formats.
When to use what:
# When to use what:
# Same-file repetition -> YAML anchors & aliases
# Cross-file (GitLab CI) -> extends + include
# Kubernetes config management -> Kustomize or Helm
# Complex logic / conditionals -> Jsonnet or Dhall
# API specifications -> JSON $ref (OpenAPI)- Same-file repetition -> YAML anchors & aliases
- Cross-file sharing in GitLab CI -> extends + include
- Kubernetes config management -> Kustomize or Helm
- Complex logic / conditionals -> Jsonnet or Dhall
- API specifications -> JSON $ref (OpenAPI)
13. FAQ
What is the difference between a YAML anchor and an alias?
An anchor (&name) marks a node so it can be referenced later. An alias (*name) is the reference that points back to the anchored node. Think of anchors as "define" and aliases as "use." You must define an anchor before you can use its alias.
Can YAML anchors work across multiple files?
No. YAML anchors are scoped to a single document within a single file. They cannot reference nodes in other files. For cross-file reuse, use tool-specific features like GitLab CI extends with include, Kubernetes Kustomize, Helm templates, or custom YAML loaders with !include tags.
What does <<: *alias do in YAML?
The << is a merge key that inserts all key-value pairs from the aliased mapping into the current mapping. It is similar to object spreading in JavaScript. Local keys take precedence over merged keys, so you can override specific fields while inheriting the rest.
Are YAML anchors a security risk?
They can be. YAML bombs (also called billion laughs attacks) use nested anchors to create exponentially large data structures that exhaust memory. Always set parsing limits when processing untrusted YAML. Most modern parsers (PyYAML SafeLoader, js-yaml safeLoad) have built-in protections against this.
Should I use YAML anchors or GitLab CI extends?
GitLab recommends extends over anchors. extends performs a deep merge (anchors do a shallow merge), works across files when combined with include, and is more readable. Use anchors only when you need to reuse individual scalar values or when extends does not cover your use case.