Elastic APM Server in Kubernetes
Published: 2026-02-14
APM Server is the ingestion endpoint for Elastic APM traces, metrics, and errors. It receives data from application agents (Go, Java, Node.js, Python) and forwards to Elasticsearch. In the OTLP world it also accepts spans over gRPC — which is how the OpenTelemetry Collector bridge connects.
This post covers the HelmRelease configuration, Elasticsearch credential management via ESO, OTLP ingestion setup, index template configuration, and the debugging workflow when spans stop arriving.
Architecture
The flow for traces in our stack:
App (OTEL SDK) → otel-collector → APM Server → Elasticsearch → Kibana APM
APM Server serves two purposes: it accepts both Elastic APM agents (native format on port 8200) and OTLP spans (gRPC on port 8200). The otel-collector in the cluster aggregates traces from multiple apps and forwards them to APM Server, which indexes them into Elasticsearch apm-* indices.
This architecture separates concerns: apps don't need to know about Elasticsearch directly, and switching from Elastic APM to a different backend only requires changing the otel-collector pipeline, not the application code.
HelmRelease
The apm-server chart is part of the elastic Helm repository. It does not use an operator — just a Deployment and a Service:
yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: apm-server
namespace: observability
spec:
chart:
spec:
chart: apm-server
version: "7.17.*"
sourceRef:
kind: HelmRepository
name: elastic
namespace: flux-system
retries: -1
values:
replicaCount: 1
config:
apm-server:
host: "0.0.0.0:8200"
rum:
enabled: true
allow_origins: ["*"]
kibana:
enabled: true
host: "http://kibana.observability.svc.cluster.local:5601"
output.elasticsearch:
hosts:
- "http://elasticsearch-master.observability.svc.cluster.local:9200"
username: "${APM_ES_USER}"
password: "${APM_ES_PASS}"
secretMounts:
- name: apm-credentials
secretName: apm-es-credentials
path: /usr/share/apm-server/config/credentials
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
retries: -1 — like all Elasticsearch-adjacent services, APM Server needs the ES cluster to be ready first, so unlimited retries for the HelmRelease. Without this, if APM Server deploys before Elasticsearch is ready, the HelmRelease fails and doesn't automatically retry.
rum.enabled: true — enables Real User Monitoring, which allows browser JavaScript to send traces directly to APM Server. allow_origins: ["*"] is convenient for development but should be restricted to your actual domain in production.
${APM_ES_USER} and ${APM_ES_PASS} — environment variables injected from the apm-es-credentials Secret via secretMounts. The credentials file is mounted at the path specified, and APM Server reads environment variables from it using Filebeat's keystore mechanism.
Resource sizing
The defaults (100m CPU, 256Mi RAM) are fine for low-traffic clusters (< 1000 requests/second). For higher traffic:
- 500m CPU is sufficient for most production workloads
- RAM scales with the number of concurrent connections and the batch size; 512Mi is safe for most setups
- For large trace volumes, consider a dedicated APM Server deployment per cluster
Elasticsearch credentials via ESO
The APM Server needs read/write access to create APM indices in Elasticsearch. These credentials live in Vault:
yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: apm-es-credentials
namespace: observability
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: apm-es-credentials
data:
- secretKey: APM_ES_USER
remoteRef:
key: secret/dev/elasticsearch
property: apm_user
- secretKey: APM_ES_PASS
remoteRef:
key: secret/dev/elasticsearch
property: apm_password
Store the credentials in Vault:
bashvault kv put secret/dev/elasticsearch \
apm_user=apm_writer \
apm_password=your-secure-password \
kibana_user=kibana_system \
kibana_password=another-secure-password
Elasticsearch user for APM
Create a dedicated Elasticsearch user with only the required permissions:
bash# In Kibana Dev Tools or via Elasticsearch API
PUT /_security/role/apm_writer
{
"indices": [
{
"names": ["apm-*"],
"privileges": ["write", "create_index", "manage"]
}
]
}
PUT /_security/user/apm_writer
{
"password": "your-secure-password",
"roles": ["apm_writer"]
}
Avoid using the elastic superuser for APM Server — if APM Server is compromised, the blast radius should be limited to APM indices only.
OTLP ingestion
APM Server 7.17 supports OTLP over gRPC on port 8200:
yamlapm-server:
otlp:
grpc:
enabled: true
host: "0.0.0.0:8200"
The otel-collector in the cluster sends spans here via:
yamlexporters:
otlp/apm:
endpoint: apm-server.observability.svc.cluster.local:8200
tls:
insecure: true
otel-collector pipeline configuration
A minimal otel-collector config that receives from apps and forwards to APM Server:
yamlreceivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1000
memory_limiter:
limit_mib: 256
exporters:
otlp/apm:
endpoint: apm-server.observability.svc.cluster.local:8200
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/apm]
The batch processor is important: without it, each span is sent as a separate gRPC call, which is extremely inefficient at high trace volumes.
Ingest pipeline for APM indices
APM creates its own index templates (apm-*). For single-node clusters with no replicas:
bashcurl -X PUT "http://elasticsearch:9200/_template/apm-server" \
-H "Content-Type: application/json" \
-d '{
"index_patterns": ["apm-*"],
"settings": {
"number_of_replicas": 0
}
}'
Without this, Elasticsearch will try to create apm-* indices with 1 replica, which will remain in YELLOW state on a single-node cluster (no replica nodes to place the replica shards).
ILM policy for APM indices
APM indices grow fast. Set up ILM to roll over and delete old data:
bashPUT /_ilm/policy/apm-rollover
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "5gb",
"max_age": "7d"
}
}
},
"delete": {
"min_age": "30d",
"actions": {
"delete": {}
}
}
}
}
}
Apply to the APM index template:
bashPUT /_template/apm-server
{
"index_patterns": ["apm-*"],
"settings": {
"number_of_replicas": 0,
"index.lifecycle.name": "apm-rollover"
}
}
Checking that spans arrive
bash# port-forward to APM server
kubectl port-forward -n observability svc/apm-server 8200:8200
# health check — should return {"ok":true}
curl http://localhost:8200/
# look for span indices in ES
curl http://localhost:9200/_cat/indices/apm-*?v
Traces appear in Kibana → APM section. Each service shows a service map, latency histogram, and error rate — all populated automatically from the OTLP spans sent by APISIX through otel-collector.
APM Server not receiving spans
Common causes:
- Connection refused — APM Server pod is not running, check
kubectl get pods -n observability - Auth error — wrong ES credentials, check
kubectl logs -n observability deploy/apm-server | grep "authentication" - OTLP port mismatch — otel-collector sending to wrong endpoint, check the collector's exporter config
- Index template missing — ES rejecting writes because replica count > available nodes
bash# Check APM Server logs
kubectl logs -n observability deploy/apm-server --tail=50
# Check ES connectivity from APM pod
kubectl exec -n observability deploy/apm-server -- \
curl -s http://elasticsearch-master.observability.svc.cluster.local:9200/_cluster/health