Jaeger with OpenTelemetry Collector on Ubuntu 24.04 on Azure User Guide
Overview
This image is a complete distributed tracing pipeline in a single virtual machine. Applications send OpenTelemetry (OTLP) telemetry to the OpenTelemetry Collector, which is the only publicly reachable ingest point. The collector limits memory, enriches every span with resource attributes, batches the stream, and exports it over OTLP to Jaeger, which stores the traces in an embedded local database and serves the trace search, timeline and service dependency views.
The two halves are wired together as one pipeline rather than shipped side by side. Jaeger does not listen on a routable interface at all: its query API, its ingest port and every health and metrics endpoint are bound to the loopback address, so every span you see in the UI physically travelled through the collector first. The trace interface is published by nginx over TLS behind a credential that is generated on your own instance the first time it boots.
What is included:
- OpenTelemetry Collector contrib 0.158.0 (Apache-2.0) as the OTLP ingest and processing gateway
- Jaeger 2.20.0 (Apache-2.0, a CNCF graduated project) as the trace store and UI
- Embedded Badger trace storage on the instance itself, so the appliance runs standalone with no external database
- A unique administrator credential, a unique telemetry ingest credential and a unique TLS certificate, all generated on first boot
- OTLP over gRPC and OTLP over HTTP, both protected by TLS and by HTTP Basic authentication
- An on instance pipeline self test that pushes a span through the collector and reads it back out of Jaeger
jaeger.service,otelcol-contrib.serviceandnginx.serviceas systemd units, enabled and active- 24/7 cloudimg support
Key facts
| Default login user | azureuser |
| Trace UI | https://<vm-public-ip>/ |
| OTLP HTTP ingest | https://<vm-public-ip>:4318/v1/traces |
| OTLP gRPC ingest | <vm-public-ip>:4317 |
| Credentials file | /etc/cloudimg-credentials.txt (mode 0600, root only) |
| TLS certificate | /etc/ssl/cloudimg/jaeger-otel-collector.crt |
| Trace storage | /var/lib/jaeger/badger |
Prerequisites
An active Azure subscription, an SSH key pair, and a virtual network and subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is the recommended starting size; the whole pipeline idles at roughly 270 MB of memory.
Network security group inbound rules:
22/tcpfrom your management network, for SSH.443/tcpfrom wherever engineers will read traces, for the Jaeger UI.4317/tcpand4318/tcpfrom your application subnet only, for telemetry ingest. These ports are the reason the product exists, so they are bound on the public interface, but they should be scoped to the workloads that send traces rather than opened to the internet. They are protected by TLS and a credential either way.
Port 80/tcp is optional and only issues a redirect to 443.
Step 1 Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Jaeger with OpenTelemetry Collector by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTPS (443). Then Review + create, then Create.
Add the two telemetry ingest ports afterwards, scoped to your application subnet.
Step 2 Deploy from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name jaeger-otel \
--image <marketplace-image-urn> \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
Open the UI port, and the ingest ports scoped to the subnet your applications run in:
az vm open-port --resource-group <your-rg> --name jaeger-otel --port 443 --priority 1001
az network nsg rule create --resource-group <your-rg> \
--nsg-name jaeger-otelNSG --name allow-otlp \
--priority 1002 --access Allow --protocol Tcp --direction Inbound \
--source-address-prefixes 10.0.1.0/24 \
--destination-port-ranges 4317 4318
Step 3 Confirm the pipeline came up
Connect over SSH and check the three services. Every one of them is gated on the first boot bootstrap completing, so active on all three means your instance generated its own credentials and certificate successfully.
ssh azureuser@<vm-public-ip>
sudo systemctl is-active jaeger otelcol-contrib nginx
Expected output:
active
active
active

Both processes also expose a health endpoint on the loopback interface:
curl -s -o /dev/null -w 'jaeger health: %{http_code}\n' http://127.0.0.1:14269/status
curl -s -o /dev/null -w 'collector health: %{http_code}\n' http://127.0.0.1:13133/
Expected output:
jaeger health: 200
collector health: 200
The pinned upstream versions and the checksums they were verified against are recorded on the instance:
/opt/otelcol-contrib/otelcol-contrib --version
cat /etc/cloudimg-jaeger-otel-collector.release
Expected output:
otelcol-contrib version 0.158.0
JAEGER_VERSION=2.20.0
JAEGER_SOURCE=https://github.com/jaegertracing/jaeger/releases/download/v2.20.0/jaeger-2.20.0-linux-amd64.tar.gz
JAEGER_BINARY_SHA256=9ee6dc46b776137ea3fa4d56a54a14d64e4b97714441b4196fb519af2c25e5c8
OTELCOL_DISTRIBUTION=otelcol-contrib
OTELCOL_VERSION=0.158.0
OTELCOL_SOURCE=https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.158.0/otelcol-contrib_0.158.0_linux_amd64.tar.gz
OTELCOL_TARBALL_SHA256=7623348c295ec7b00d86c30040a30730f7e3537e813b34c880c1d5abb9bbe8d5

Step 4 Read your instance credentials
Nothing in this image ships with a password. Your instance generates its own administrator credential, its own telemetry ingest credential and its own TLS certificate the first time it boots, and writes them to a root only file. The endpoint addresses and usernames are:
sudo grep -E '^(JAEGER_UI_URL|JAEGER_UI_USERNAME|OTLP_HTTP_URL|OTLP_GRPC_ENDPOINT|OTLP_INGEST_USERNAME|OTLP_TLS_CERTIFICATE)=' /etc/cloudimg-credentials.txt
Example output (the address will be your own instance's):
JAEGER_UI_URL=https://10.0.0.10/
JAEGER_UI_USERNAME=cloudimg
OTLP_HTTP_URL=https://10.0.0.10:4318
OTLP_GRPC_ENDPOINT=10.0.0.10:4317
OTLP_INGEST_USERNAME=otel-ingest
OTLP_TLS_CERTIFICATE=/etc/ssl/cloudimg/jaeger-otel-collector.crt
Read the two passwords with:
sudo grep PASSWORD /etc/cloudimg-credentials.txt
JAEGER_UI_PASSWORD signs you in to the trace interface. OTLP_INGEST_PASSWORD is what your applications present when they send telemetry. They are different values, and neither exists anywhere else.
Azure's instance metadata service returns an empty value for a Standard SKU public IP, so the addresses written at first boot are your instance's private address. Use the public IP of the VM from the browser and from anything outside the virtual network.
Step 5 Prove the pipeline carries a span
The image ships with the same end to end check that cloudimg runs before release. It pushes a span into the collector with your instance's ingest credential, then reads that exact trace back out of Jaeger through the authenticated front door.
sudo /usr/local/sbin/cloudimg-jaeger-e2e-probe.sh
Expected output (your trace and span identifiers will differ):
E2E_PIPELINE_OK trace=019b1a6615b82cded612a6d60c64aa24 span=140756abee2060b6 operation=cloudimg-e2e-probe
If that line prints, telemetry ingest, the collector's processing pipeline, the export to Jaeger, trace storage and the authenticated query path are all working together.

Step 6 Send your own span
You can drive the same path by hand with curl. This sends one OTLP span over HTTP as a service called my-first-service, then reads the trace back from Jaeger.
CERT=/etc/ssl/cloudimg/jaeger-otel-collector.crt
TRACE_ID=$(openssl rand -hex 16)
SPAN_ID=$(openssl rand -hex 8)
NOW=$(date +%s)000000000
cat > /tmp/my-span.json <<JSON
{"resourceSpans":[{"resource":{"attributes":[
{"key":"service.name","value":{"stringValue":"my-first-service"}}]},
"scopeSpans":[{"scope":{"name":"manual"},"spans":[
{"traceId":"$TRACE_ID","spanId":"$SPAN_ID","name":"my-first-span","kind":1,
"startTimeUnixNano":"$NOW","endTimeUnixNano":"$NOW"}]}]}]}
JSON
INGEST_PASS=$(sudo sed -n 's/^OTLP_INGEST_PASSWORD=//p' /etc/cloudimg-credentials.txt)
UI_PASS=$(sudo sed -n 's/^JAEGER_UI_PASSWORD=//p' /etc/cloudimg-credentials.txt)
curl -s -o /dev/null -w 'ingest accepted: %{http_code}\n' --cacert "$CERT" \
-u "otel-ingest:$INGEST_PASS" -H 'Content-Type: application/json' \
--data-binary @/tmp/my-span.json https://127.0.0.1:4318/v1/traces
sleep 8
curl -s --cacert "$CERT" -u "cloudimg:$UI_PASS" \
"https://127.0.0.1/api/traces/$TRACE_ID" | head -c 160
echo
rm -f /tmp/my-span.json
Expected output (the trace body will contain your generated identifiers):
ingest accepted: 200
{"data":[{"traceID":"...","spans":[{"traceID":"...","spanID":"...","operationName":"my-first-span",
Step 7 Browse the trace interface
Open https://<vm-public-ip>/ in a browser and sign in with cloudimg and JAEGER_UI_PASSWORD. Because the certificate is generated on your own instance and signed by itself, the browser will warn the first time; see Replacing the TLS certificate below to issue one for your own name.
The search view lists every service that has sent telemetry through the collector.

Choosing a service and searching returns the matching traces with their duration, span count and the services each one touched. Failed requests are flagged in the errors column and stand out in red on the duration scatter plot.

Opening a trace draws the full request timeline. This is what the pipeline is for: one request fanning out across five services, with each span's duration and parent relationship laid out so a slow call is obvious at a glance.

The System Architecture view builds a service dependency graph from the spans the collector delivered, with the call counts on each edge.

Step 8 Point a real application at the collector
Any OpenTelemetry SDK or agent can send to this instance. Set the standard OTLP environment variables on your application, using your instance address, the ingest credential from Step 4, and the instance certificate as the trust anchor. Copy /etc/ssl/cloudimg/jaeger-otel-collector.crt from the VM to your application host first.
OTLP over HTTP:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://<vm-public-ip>:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/jaeger-otel-collector.crt
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic $(printf 'otel-ingest:<OTLP_INGEST_PASSWORD>' | base64 -w0)"
export OTEL_SERVICE_NAME=my-service
OTLP over gRPC uses the same credential on port 4317:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://<vm-public-ip>:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/jaeger-otel-collector.crt
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic $(printf 'otel-ingest:<OTLP_INGEST_PASSWORD>' | base64 -w0)"
export OTEL_SERVICE_NAME=my-service
Traces appear in the search view within a few seconds of the collector batching them.
The listener and authentication model
Only four ports face the network, and both of the ones that accept data require a credential.
sudo ss -ltn | grep -E ':(80|443|4317|4318|16686|14317|14269|13133|8888)'
Expected output:
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 511 0.0.0.0:443 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:14317 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:14269 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:16686 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:16685 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:13133 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:8888 0.0.0.0:*
LISTEN 0 4096 *:4318 *:*
LISTEN 0 4096 *:4317 *:*

| Port | Interface | What it is | Protection |
|---|---|---|---|
| 443 | public | Jaeger trace UI and query API via nginx | TLS plus bcrypt basic authentication |
| 80 | public | Redirect to 443 only | no content served |
| 4318 | public | Collector OTLP over HTTP | TLS plus bcrypt basic authentication |
| 4317 | public | Collector OTLP over gRPC | TLS plus bcrypt basic authentication |
| 16686, 16685 | loopback | Jaeger query HTTP and gRPC | not reachable off the instance |
| 14317 | loopback | Jaeger OTLP ingest, from the collector only | not reachable off the instance |
| 14269, 14271 | loopback | Jaeger health and metrics | not reachable off the instance |
| 13133, 8888 | loopback | Collector health and metrics | not reachable off the instance |
Neither process enables zPages or pprof. Confirm that unauthenticated callers are turned away:
CERT=/etc/ssl/cloudimg/jaeger-otel-collector.crt
echo "anonymous UI: $(curl -s -o /dev/null -w '%{http_code}' --cacert $CERT https://127.0.0.1/)"
echo "anonymous OTLP ingest: $(curl -s -o /dev/null -w '%{http_code}' --cacert $CERT -X POST -H 'Content-Type: application/json' -d '{}' https://127.0.0.1:4318/v1/traces)"
Expected output:
anonymous UI: 401
anonymous OTLP ingest: 401
How first boot protects the instance
The image contains no credential and no certificate. On first boot jaeger-otel-collector-firstboot.service generates both passwords and the TLS keypair, writes them to the root only credentials file, and then creates a bootstrap marker. All three services carry ConditionPathExists on that marker and run a start time guard before they launch, so if the bootstrap has not run they do not start at all rather than starting with anything shipped in the image. The guard also refuses to run when a credential is too short, when it matches a published example value, or when the certificate and private key do not match.
systemctl is-enabled jaeger-otel-collector-firstboot.service jaeger.service otelcol-contrib.service nginx.service
Expected output:
enabled
enabled
enabled
enabled
Replacing the TLS certificate
The first boot certificate covers the loopback address, the instance hostname and the private IP, because Azure does not report a Standard SKU public IP through instance metadata. Re issue it with the names you actually use, which keeps the existing local names as well:
sudo /usr/local/sbin/cloudimg-jaeger-tls-reissue.sh <vm-public-ip> tracing.example.com
The helper writes the new keypair, reloads nginx and restarts the collector. For a certificate a browser trusts without a warning, point a DNS record at the instance and use certbot instead: install it with sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx, then run sudo certbot --nginx -d tracing.example.com. Update the collector's cert_file and key_file in /etc/otelcol-contrib/config.yaml to the certbot paths if you want the ingest ports to use the same certificate.
Trace storage
Traces are stored in an embedded Badger database on the instance, so nothing external needs provisioning. Live traces are kept for 72 hours and the archive store for 30 days.
sudo du -sh /var/lib/jaeger/badger
df -h /var/lib/jaeger | tail -1
Example output:
36K /var/lib/jaeger/badger
/dev/root 29G 2.8G 26G 10% /var/lib/jaeger
To keep traces for longer, raise ttl.spans under jaeger_storage in /etc/jaeger/config.yaml and restart jaeger.service. For volumes beyond a single instance, change the same backend block to Elasticsearch, OpenSearch or Cassandra following the Jaeger storage documentation; the collector configuration does not change, because it always exports to the local Jaeger endpoint.
Server components
| Component | Version | Purpose |
|---|---|---|
| OpenTelemetry Collector contrib | 0.158.0 | OTLP ingest, memory limiting, resource detection, batching, export to Jaeger |
| Jaeger | 2.20.0 | Trace storage, query API, web interface |
| nginx | 1.24.0 | TLS termination and authentication in front of the trace interface |
| Ubuntu Server | 24.04 LTS | Operating system, fully patched with unattended security upgrades enabled |
Filesystem layout
| Path | Purpose |
|---|---|
/opt/jaeger |
Jaeger binary, symlinked to the pinned version directory |
/opt/otelcol-contrib |
Collector binary, symlinked to the pinned version directory |
/etc/jaeger/config.yaml |
Jaeger configuration: storage backends, query endpoints, health |
/etc/otelcol-contrib/config.yaml |
Collector configuration: receivers, processors, exporter |
/etc/otelcol-contrib/.htpasswd-ingest |
Telemetry ingest credential, bcrypt |
/etc/nginx/.htpasswd-jaeger-otel |
Trace interface credential, bcrypt |
/etc/ssl/cloudimg/ |
Per instance TLS certificate and key |
/etc/cloudimg-credentials.txt |
Per instance credentials, mode 0600 root only |
/var/lib/jaeger/badger |
Live trace storage |
/var/lib/jaeger/badger-archive |
Archive trace storage |
/var/lib/cloudimg/ |
First boot sentinel and bootstrap marker |
Scripts and log files
| Path | Purpose |
|---|---|
/usr/local/sbin/cloudimg-jaeger-e2e-probe.sh |
End to end pipeline self test |
/usr/local/sbin/cloudimg-jaeger-tls-reissue.sh |
Re issue the instance TLS certificate with extra names |
/usr/local/sbin/jaeger-otel-guard.sh |
Start time bootstrap guard, run before each service |
/usr/local/sbin/jaeger-otel-collector-firstboot.sh |
First boot secret and certificate generation |
/var/log/cloudimg-firstboot.log |
First boot log |
Maintenance
- Service logs:
sudo journalctl -u otelcol-contrib.service -ffollows the ingest side,sudo journalctl -u jaeger.service -fthe storage and query side. - Restart:
sudo systemctl restart jaeger.service otelcol-contrib.service nginx.service. - OS updates: unattended security upgrades stay enabled. Apply everything pending at any time with
sudo apt-get update && sudo apt-get upgrade -y. - Rotating the trace interface password:
sudo htpasswd -B /etc/nginx/.htpasswd-jaeger-otel cloudimg, thensudo systemctl reload nginx. Record the new value in/etc/cloudimg-credentials.txtso the self test keeps working. - Rotating the ingest credential:
sudo htpasswd -B /etc/otelcol-contrib/.htpasswd-ingest otel-ingest, thensudo systemctl restart otelcol-contrib. Update your applications at the same time.
Troubleshooting
A service is inactive. All three are gated on the first boot bootstrap. Check it completed: sudo systemctl status jaeger-otel-collector-firstboot.service and ls -l /var/lib/cloudimg/. If the bootstrap marker is missing, the guard is doing its job and refusing to start the appliance with no credentials. Read /var/log/cloudimg-firstboot.log for the reason.
Spans are accepted but do not appear. The collector batches for up to two seconds and Jaeger writes asynchronously, so allow a few seconds. If they still do not appear, check the collector is reaching Jaeger with sudo journalctl -u otelcol-contrib.service -n 50, and confirm Jaeger's ingest port is listening with sudo ss -ltn | grep 14317.
Ingest returns 401. The credential is wrong, or the header is missing. Confirm with sudo grep OTLP_INGEST_PASSWORD /etc/cloudimg-credentials.txt and remember the username is otel-ingest, not cloudimg.
Ingest returns a TLS error. Your client does not trust the instance certificate. Copy /etc/ssl/cloudimg/jaeger-otel-collector.crt to the client and point OTEL_EXPORTER_OTLP_CERTIFICATE at it, or re issue the certificate for the name your client connects to.
The browser warns about the certificate. That is expected for a certificate the instance signed itself. Use certbot for a publicly trusted certificate, as described above.
Security recommendations
- Scope the network security group rules for
4317and4318to your application subnet, and443to the networks your engineers use. Do not publish either to the internet. - Replace the first boot certificate with one issued for a name you control, so clients verify it properly rather than pinning a self signed file.
- Keep
/etc/cloudimg-credentials.txtat mode 0600 and owned by root. The start time guard refuses to launch the services if it is loosened. - Rotate both credentials after handing the instance over, using the commands in Maintenance.
- Leave the loopback bindings alone. Jaeger's query API has no authentication of its own; nginx is what protects it.
Support
This image is maintained by cloudimg with 24/7 support. Contact support@cloudimg.co.uk for assistance.