How to Setup ELK Stack in Docker: Step-by-Step Guide

This entry is part 2 of 2 in the series ELK Stack Series

ELK Stack Series

ELK Stack Diagram: Logs from applications collected by Logstash, stored in Elasticsearch, and visualized with Kibana.

What is the ELK Stack? Master Elasticsearch, Logstash, and Kibana with Real-World Examples

Set up ELK Stack in Docker showing Logstash, Elasticsearch, and Kibana pipeline for centralized log management

How to Setup ELK Stack in Docker: Step-by-Step Guide

ELK Stack setup in Docker showing Logstash, Elasticsearch, and Kibana pipeline for centralized log management
Setting up the ELK Stack (Elasticsearch, Logstash, Kibana) using Docker for efficient log management.

Setup ELK Stack in Docker to simplify log management without the hassle of manual installations. In this guide, we’ll walk through containerizing Elasticsearch, Logstash, and Kibana using Docker Compose β€” so you can spin up a fully working ELK Stack in minutes and start collecting, processing, and visualizing logs right away.

Introduction: Observability with ELK Stack

Observability is the backbone of any reliable, production-grade application. Without proper logging, debugging issues in distributed systems or microservices becomes nearly impossible. This is where the ELK Stack β€” Elasticsearch, Logstash, and Kibana β€” comes in.

We’ve already covered the fundamentals in detail in our earlier post: What is the ELK Stack?. If you’re new to ELK, you can go through it later. For everyone else, here’s a quick recap.

Our application, or our microservices, generate logs. These logs can be written into log files. Then, Logstash collects or reads these logs, processes and transforms them if required, and sends them to Elasticsearch. Elasticsearch stores and indexes these logs, making them easy to search and analyze. And finally, we use Kibana to visualize and monitor these logs through dashboards and a user-friendly interface.

So, in simple terms, our flow looks like this:

ELK Stack log pipeline Three apps write log files that merge into Logstash, flow to Elasticsearch, into Kibana, then are searched. App 1 App 2 App 3 app1.log app2.log app3.log Logstash Collect, filter Elasticsearch Store, index Kibana Dashboards Search and analyze Application β†’ Log File β†’ Logstash β†’ Elasticsearch β†’ Kibana

In this post, we’ll integrate our Loyalty Application with the ELK Stack using Docker β€” giving you a real, working example instead of just theory.

Loyalty Application

What is a Loyalty Application?

A loyalty application is used to manage customer loyalty. It’s a common building block in e-commerce and retail systems, and a great real-world candidate for demonstrating observability, since it involves multiple transactional flows worth logging and monitoring.

The basic flow of our application is:

  • First, we register a customer.
  • Once the customer is registered, we can provide some loyalty points to the customer’s wallet.
  • The customer can then use or redeem those loyalty points while making a purchase.
  • And if required, the redeemed points can also be refunded.

This gives us a simple but realistic transaction flow that we can use to demonstrate application logging.

Loyalty application transaction flow with wallet balance Sequence showing customer registration, point earning, point redemption, and point refund, with wallet balance changing at each step. 1. Register customer 2. Earn points +4,000 pts 3. Redeem points -800 pts 4. Refund points +800 pts Wallet created 0 pts 0 + 4,000 4,000 pts 4,000 – 800 3,200 pts 3,200 + 800 4,000 pts Register β†’ Earn β†’ Redeem β†’ Refund

Full source code for the Loyalty Application used in this guide is available on GitHub.

Loyalty Application API

Customer APIs

The first category is Customer APIs. We have APIs for creating a customer and performing the basic CRUD operations on the customer.

  1. Create Customer
  2. Get Customer
  3. Get All Customers
  4. Update Customer
  5. Delete Customer
  6. Activate Customer
  7. Deactivate Customer

Transaction APIs

  1. Earn Point
  2. Redeem Point
  3. Refund Point
  4. Get Transactions

Loyalty Management API Postman collection.

API Sequence Flow

Learn how to build a REST API with Spring Boot, MySQL, and Docker in our complete step-by-step guide.

1. Create Customer

Registers a new customer in the system. The request first passes through validation β€” if the email already exists, the flow short-circuits with a 409 Conflict; otherwise, the customer is persisted and a 201 Created response is returned.

2. Earn Points

Credits loyalty points to a customer’s wallet. The system first verifies the customer exists and is active β€” if not found, it returns a 404; otherwise, the points are credited and the wallet balance is updated.

3. Redeem Points

Deducts loyalty points from a customer’s wallet during a purchase. The system verifies the customer exists and has sufficient balance β€” if the customer isn’t found, it returns a 404; if the balance is too low, a 400; otherwise, points are debited and the transaction completes.

4. Refund Points

Reverses a previous redemption and credits the points back to the customer’s wallet. The system verifies the customer, confirms the original redemption transaction exists, and checks it hasn’t already been refunded β€” before crediting the points back.

Application Code Structure

Here’s the layered package structure for our Loyalty Application, built on standard Spring Boot conventions β€” each package has a single responsibility, which also makes it easy to trace exactly which layer a log line came from once ELK is wired in.

com.loyalty.loyaltyprogram β”œβ”€β”€ LoyaltyProgramApplication.java β†’ Spring Boot entry point β”œβ”€β”€ controller β†’ REST endpoints (HTTP layer) β”‚ β”œβ”€β”€ CustomerController.java β”‚ └── TransactionController.java β”œβ”€β”€ service β†’ Business logic contracts (interfaces) β”‚ β”œβ”€β”€ CustomerService.java β”‚ └── TransactionService.java β”œβ”€β”€ service.impl β†’ Business logic implementation β”‚ β”œβ”€β”€ CustomerServiceImpl.java β”‚ └── TransactionServiceImpl.java β”œβ”€β”€ repository β†’ Data access (Spring Data JPA) β”‚ β”œβ”€β”€ CustomerRepository.java β”‚ └── TransactionRepository.java β”œβ”€β”€ model β†’ JPA entities β”‚ β”œβ”€β”€ Customer.java β”‚ └── Transaction.java β”œβ”€β”€ dto.request β†’ Incoming request payloads β”‚ β”œβ”€β”€ CustomerRequestDto.java β”‚ β”œβ”€β”€ AddPointsRequestDto.java β”‚ β”œβ”€β”€ RedeemRequestDto.java β”‚ └── RefundRequestDto.java β”œβ”€β”€ dto.response β†’ Outgoing response wrapper β”‚ └── ApiResponse.java β”œβ”€β”€ enums β†’ Domain constants β”‚ β”œβ”€β”€ AccountStatus.java β”‚ β”œβ”€β”€ TransactionStatus.java β”‚ └── TransactionType.java └── exception β†’ Custom exceptions + centralized handling β”œβ”€β”€ GlobalExceptionHandler.java β”œβ”€β”€ CustomerNotFoundException.java β”œβ”€β”€ CustomerAccountDeactivatedException.java β”œβ”€β”€ DuplicateResourceException.java β”œβ”€β”€ PointsNotAvailableException.java └── TransactionNotFoundException.java

Why this structure works well:

  • Controller β†’ Service β†’ Repository is a classic 3-tier separation: controllers only handle HTTP concerns, services hold business rules, repositories talk to the database.
  • Service as an interface + service.impl keeps the contract decoupled from the implementation β€” useful if you ever need to swap logic or mock the service layer in tests.
  • DTOs are split by direction (dto.request vs dto.response) so request validation and response shaping don’t leak into each other.
  • A single GlobalExceptionHandler centralizes error handling instead of scattering try-catch blocks across controllers β€” this is also what feeds the ERROR-level logs you saw flowing into Logstash in the sequence diagrams above.

Log Management: Preparing Application Logs for ELK Stack

Before Logstash can do anything useful, the application itself needs to produce logs that are consistent, structured, and traceable. This isn’t a Logstash concern β€” it’s entirely on the application side, and it’s the difference between logs you can read and logs you can query. We’ll do this in three stages: generating logs into a shared location, formatting them as JSON, and enriching them with tracing metadata.

Log Generation

In a real deployment, logs shouldn’t live inside the application’s own folder β€” if the app gets redeployed or the container is rebuilt, you don’t want your log history tied to that lifecycle. So the first step is creating a folder outside the application as elk-resource, parallel to it, that will act as the shared log source for Logstash.

Inside elk-resource, a logs folder will hold every log file.

Now add this to application.properties:

# ========================= # Logging levels # ========================= logging.level.root=INFO # Optional: Ensure Hibernate SQL category stays quiet at WARN level logging.level.org.hibernate.SQL=WARN # =================================================================== # Service & Instance-Wise Dynamic File Logging # =================================================================== # Directory outside the application root, in the shared elk-resource folder logging.file.path=../elk-resource/logs # Dynamically name log files per service & container/instance ID # Example output: loyalty-program-loyalty-inst-1.log logging.file.name=../elk-resource/logs/${spring.application.name}-${HOSTNAME:localhost}.log # =================================================================== # Log Rotation & Retention Settings (Rolling File Appender) # =================================================================== # Max size per single log file before rotating logging.logback.rollingpolicy.max-file-size=10MB # Maximum total cap across all archived log files logging.logback.rollingpolicy.total-size-cap=1GB # Keep archived logs for 30 days logging.logback.rollingpolicy.max-history=30

Why this matters in production: a real system rarely runs a single instance of a single service. You’ll likely have multiple microservices, and each microservice may run multiple instances behind a load balancer. If every instance wrote to the same log file, you’d get interleaved, unattributable log lines. Naming each file as ${spring.application.name}-${HOSTNAME} means every instance of every service writes to its own file, but all of those files land in the same shared logs folder β€” which is exactly what Logstash will watch.

On rotation: Logback creates a new file per day, per service, per instance. If that day’s file crosses the 10MB cap before the day ends, it rotates into an additional numbered file for that same day rather than waiting for midnight β€” so you might see loyalty-program-localhost.2026-08-21.0.log, .1.log, and so on if traffic is heavy. Files older than 30 days are cleaned up automatically, and the total archive is capped at 1GB regardless of age.

Restart the application, and you’ll find the logs folder auto-created inside elk-resource. If you’re running 5 instances of the service, you’ll see 5 separate .log files β€” one per instance, each named by service + hostname.

Spring Boot plain text log output showing loyalty-program EARN transaction flow
Default Logback output for the loyalty-program service β€” human-readable, but not structured, which makes filtering on fields like customerId or transactionType inside Kibana difficult.

Log Optimization (Structured JSON Logging)

Looking at the log file above, each entry is a single line β€” but it’s plain text. Logstash can still read this and push it to Elasticsearch, and you’ll be able to view it in Kibana. The problem is what you can’t do with it: you can’t filter on customerId, you can’t build an alert on level: ERROR, and you can’t chart transactionType over time, because none of those are separate, queryable fields β€” they’re just substrings inside one long message.

The fix is to make the application emit structured JSON logs instead of plain text, so each field (timestamp, level, logger, message, and anything else we attach) becomes independently searchable in Elasticsearch.

If you’re on Spring Boot 3.4.0 or later: structured logging is built in β€” no extra dependency needed. Just add this to application.properties:

# Structured Logstash JSON formatting for file logs (Spring Boot 3.4+) logging.structured.format.file=logstash

If you’re on Spring Boot below 3.4.0: there’s no built-in structured formatter, so you’ll configure Logback directly using the logstash-logback-encoder library.

Add the dependency:

<dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>7.4</version> </dependency>


Then create logback-spring.xml in src/main/resources, mirroring the same file-naming and rotation rules you set in application.properties earlier:

<?xml version=”1.0″ encoding=”UTF-8″?> <configuration> <springProperty scope=”context” name=”appName” source=”spring.application.name” defaultValue=”loyalty-program”/> <property name=”LOG_PATH” value=”../elk-resource/logs”/> <property name=”INSTANCE_ID” value=”${HOSTNAME:-localhost}”/> <appender name=”JSON_FILE” class=”ch.qos.logback.core.rolling.RollingFileAppender”> <file>${LOG_PATH}/${appName}-${INSTANCE_ID}.log</file> <rollingPolicy class=”ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy”> <fileNamePattern>${LOG_PATH}/${appName}-${INSTANCE_ID}.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>10MB</maxFileSize> <totalSizeCap>1GB</totalSizeCap> <maxHistory>30</maxHistory> </rollingPolicy> <encoder class=”net.logstash.logback.encoder.LogstashEncoder”/> </appender> <root level=”INFO”> <appender-ref ref=”JSON_FILE”/> </root> <logger name=”org.hibernate.SQL” level=”WARN”/> </configuration>

This keeps the exact same rotation behavior (10MB per file, 1GB total cap, 30-day retention) β€” just with LogstashEncoder producing JSON instead of Logback’s default plain-text pattern.

Restart the application either way, and the log file now looks like this:

{ “@timestamp“: “2026-08-23T20:17:27.3435112+05:30”, “@version“: “1”, “message“: “REDEEM transaction completed successfully | redeemTxnId: 89b964ff-5fab-4b9e-a98d-a13b1a3c9d3b, customerId: 3, pointsRedeemed: 100.0”, “logger_name“: “com.loyalty.loyaltyprogram.service.impl.TransactionServiceImpl”, “thread_name“: “http-nio-8081-exec-2”, “level“: “INFO”, “level_value“: 20000 }
A single structured JSON log entry β€” level, logger_name, and requestId as independently searchable fields.
A single structured JSON log entry β€” level, logger_name, and requestId as independently searchable fields.
Spring Boot JSON log file for REDEEM transaction with requestId tracing, Logstash ready
Four log lines from one REDEEM request, linked by a shared requestId for end-to-end tracing in Elasticsearch.

Once Logstash picks these up and pushes them to Elasticsearch, you can filter, search, and build dashboards in Kibana on real fields β€” not just full-text search across a string.

Log Enhancement (Request Tracing with MDC)

JSON solves structure, but not traceability. In production, an application runs on a multithreaded server handling many concurrent API calls β€” so logs from ten different requests can interleave in the same file. Without something tying each log line back to the request that produced it, tracing a single transaction through the noise is nearly impossible.

The standard fix is a request ID: generate one ID per incoming request, attach it to every log line that request produces (via SLF4J’s MDC β€” Mapped Diagnostic Context, which is thread-local), and return it to the caller in the response. If a user reports an issue, they hand you the request ID, and you filter Kibana on it β€” done.

While we’re at it, it’s worth attaching a few more fields for the same reason multi-instance file naming mattered earlier in Log Generation: once Logstash merges every service’s and every instance’s logs into one Elasticsearch index, you lose the “which file did this come from” context you had on disk. So we also tag each log line with serviceName and instanceId, and β€” specific to this app β€” transactionType (EARNED / REDEEM / REFUND), since that’s a natural way to slice transaction logs in Kibana.

Create a filter package and add LoggingContextFilter:

package com.loyalty.loyaltyprogram.filter; import com.loyalty.loyaltyprogram.enums.TransactionType; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.io.IOException; import java.util.UUID; /** * Populates MDC with per-request context β€” requestId, transactionType, * serviceName, instanceId β€” so every structured log line can be traced * back to its request, service, and instance once merged in Elasticsearch. * Cleared in {@code finally} to avoid leaking into pooled threads. */ @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class LoggingContextFilter implements Filter { public static final String REQUEST_ID_KEY = “requestId”; public static final String TXN_TYPE_KEY = “transactionType”; public static final String SERVICE_NAME_KEY = “serviceName”; public static final String INSTANCE_ID_KEY = “instanceId”; public static final String REQUEST_ID_HEADER = “X-Request-ID”; @Value(“${spring.application.name:loyalty-program}”) private String applicationName; private static final String HOST_NAME = System.getenv().getOrDefault(“HOSTNAME”, “localhost”); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; try { // 1. Populate Application & Host Metadata MDC.put(SERVICE_NAME_KEY, applicationName); MDC.put(INSTANCE_ID_KEY, HOST_NAME); // 2. Handle Request Tracing ID String requestId = httpRequest.getHeader(REQUEST_ID_HEADER); if (!StringUtils.hasText(requestId)) { requestId = UUID.randomUUID().toString(); } MDC.put(REQUEST_ID_KEY, requestId); // 3. Infer Transaction Type from Endpoint URI String uri = httpRequest.getRequestURI(); if (uri.endsWith(“/earn”)) { MDC.put(TXN_TYPE_KEY, TransactionType.EARNED.name()); } else if (uri.endsWith(“/redeem”)) { MDC.put(TXN_TYPE_KEY, TransactionType.REDEEM.name()); } else if (uri.endsWith(“/refund”)) { MDC.put(TXN_TYPE_KEY, TransactionType.REFUND.name()); } else { MDC.put(TXN_TYPE_KEY, “NON-TXN”); } httpResponse.setHeader(REQUEST_ID_HEADER, requestId); chain.doFilter(request, response); } finally { // Clear all injected keys to prevent thread reuse pollution MDC.remove(REQUEST_ID_KEY); MDC.remove(TXN_TYPE_KEY); MDC.remove(SERVICE_NAME_KEY); MDC.remove(INSTANCE_ID_KEY); } } }

The finally block matters β€” since threads are pooled and reused across requests, forgetting to clear MDC would leak one request’s requestId into the next request served by the same thread.

Then surface the request ID back to the caller by adding it to ApiResponse:

package com.loyalty.loyaltyprogram.dto.response; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import org.slf4j.MDC; @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class ApiResponse<T> { private String requestId; private int status; private String message; private LocalDateTime timestamp; private List<String> errors; private T data; public static <T> ApiResponse<T> success(int status, String message, T data) { return ApiResponse.<T>builder() .requestId(MDC.get(“requestId”)) .status(status) .message(message) .timestamp(LocalDateTime.now()) .data(data) .build(); } public static <T> ApiResponse<T> failure(int status, String message, List<String> errors) { return ApiResponse.<T>builder() .requestId(MDC.get(“requestId”)) .status(status) .message(message) .timestamp(LocalDateTime.now()) .errors(errors == null ? Collections.emptyList() : errors) .build(); } }

Now if a user hits an issue, the response itself hands them the requestId they need to give you β€” no digging required on their end.

Restart the application, and the logs are now fully enriched:

Enriched Spring Boot Logstash JSON log output showing injected MDC metadata fields including transactionType, instanceId, serviceName, and requestId
Structured JSON log output capturing Spring Boot application logs enriched with MDC key-value pairs (requestId, transactionType, serviceName, instanceId) for centralized log analysis.
Spring Boot REST API JSON response payload featuring unified requestId for distributed tracing and transaction details
Standardized REST API JSON response wrapper returning a 201 status alongside the injected requestId to match client-side requests with backend ELK logs.

Log Enrichment (Key-Value Structured Logging)

At this point every log line already carries requestId, transactionType, serviceName, and instanceId as independent, filterable fields. That’s a big win β€” but look closely at the message field itself:

“message”: “EARN transaction completed successfully | earnTxnId: 1c488b8d-6b5d-4638-96b7-3fb78ad354b1, customerId: 3, pointsEarned: 100.0”

earnTxnId, customerId, and pointsEarned are sitting inside one long string. Elasticsearch can full-text search it, but you can’t filter on customerId: 3 or aggregate pointsEarned over time β€” they’re just substrings, not fields.

From String Interpolation to Key-Value Logging

The fix is to stop building the message with {} placeholders and switch to SLF4J’s fluent, key-value logging API instead. Each addKeyValue() call becomes its own independent JSON field once logstash-logback-encoder writes the log β€” so message stays a short, human-readable summary, and everything else becomes queryable on its own. We’ll apply the same fluent API to the transaction fields (earnTxnId, customerId, pointsEarned) as well as the request and response bodies, so both are searchable in Kibana instead of being buried inside one long log line.

// Old way β€” everything buried in one string:
“message”: “EARN transaction completed successfully | earnTxnId: 1c488b8d-6b5d-4638-96b7-3fb78ad354b1,
customerId: 3, pointsEarned: 100.0″


// New way β€” every field independently queryable
log.atInfo().setMessage(“EARN transaction completed successfully”)
.addKeyValue(“earnTxnId”, saved.getEarnTxnId())
.addKeyValue(“customerId”, customer.getCustomerId())
.addKeyValue(“pointsEarned”, saved.getPoints())
.addKeyValue(“payload”, toJsonString(saved))
.log();

Print Key-Values on Console Too

Add this to application.properties so the same key-value pairs show up while you’re watching console output locally, not just in the JSON file:

# =================================================================== # Console Logging Pattern (with MDC request tracing) # ==============================================
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:-} — [%15.15t] [requestId: %X{requestId}] [txnType: %X{transactionType}] %-40.40logger{36} : %m %kvp%n

Now restart the application, hit the EARN API again, and you’ll see the logs printed with every field broken out separately.

JSON log with earnTxnId, customerId, pointsEarned and payload as separate fields
Structured EARN log
JSON log with earnTxnId, customerId, pointsEarned embedded inside the message string
Old-style EARN log

That’s the shift β€” from “logs you can read” to “logs you can query.” message is now earnTxnId, customerId, pointsEarned, and payload are all first-class fields Elasticsearch can filter and aggregate on.

Now our log generation is standard and production-based, and we can move toward the ELK setup.

Setup ELK Stack in Docker

With structured, enriched logs already writing into elk-resource/logs, the ELK side is just three containers: Elasticsearch to store and index, Logstash to read the log files and ship them, Kibana to search and visualize.

Setup ELK Stack in Docker showing active containers for Logstash, Kibana, and Elasticsearch running in Docker Desktop UI
Docker Desktop dashboard showing active Logstash, Kibana, and Elasticsearch containers configured and running smoothly.
Basic Frequently Useful Docker Commands :
# Check installed Docker version
docker – -version
# List all downloaded images docker images
# List running containers docker ps
# List all containers, including stopped ones docker ps -a
# Run a container with a custom name docker run – -name <container_name> <image>
# List all Docker networks docker network ls
# Inspect a network β€” see which containers are attached docker network inspect elk-network
# Stream live logs from a container docker logs -f <container_name>
# Open a shell inside a running container docker exec -it <container_name> bash
# Stop a running container docker stop <container_name>
# Remove a stopped container docker rm <container_name>

Setting Up the Docker Network

Inside a container, localhost means that container itself β€” not the host machine, and not any other container. Elasticsearch, Logstash, and Kibana would each run in their own isolated container with their own localhost, so if Logstash tries to reach Elasticsearch at localhost:9200, it’s really just knocking on its own door.

The fix is a Docker network. Once containers are attached to the same custom network, they can reach each other directly by container name instead of localhost β€” Docker’s internal DNS resolves the name to the right container.

Containers attached to a Docker network elk-network bridge sits above logstash, elasticsearch, and kibana containers, connecting each by container name. elk-network bridge logstash elasticsearch kibana Logstash ships logs to Elasticsearch, Kibana queries Elasticsearch β€” all via elk-network
docker network create elk-network

Setup Elasticsearch

Elasticsearch has to come up first β€” Logstash and Kibana both depend on it being reachable. First, let’s create a folder called elasticsearch-data inside elk-resource.

docker run -d --name elasticsearch --network elk-network -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -e "xpack.security.enabled=false" -e "ES_JAVA_OPTS=-Xms1g -Xmx1g" -v "/mnt/e/CodeOps Trek Tutorial WorkSpace/ELK Tutorial/ELK-Implementation-Work Space/elk-resource/elasticsearch-data:/usr/share/elasticsearch/data" docker.elastic.co/elasticsearch/elasticsearch:8.13.4
  • --name β€” container name: elasticsearch
  • --network β€” network it joins: elk-network
  • -p β€” ports exposed to the host: 9200 (REST API), 9300 (node communication)
  • -v β€” named volume so index data survives container restarts
  • last line β€” the image: elasticsearch:8.13.4

Give it 20–30 seconds to fully initialize before starting Logstash. Once it’s up, you can list every index at: http://localhost:9200/_cat/indices?v

Setup Logstash

Before running the Logstash container, it needs a pipeline definition β€” a config file telling it where to pull logs from, where to push them, and how to filter them along the way.

logstash.conf

Create a logstash-pipeline folder inside elk-resource, and add logstash.conf inside it.

Input β€” reads every .log file in the shared logs folder, treats each line as one JSON object, and keeps track of its read position via sincedb so restarts don’t re-ingest old logs:

Filter β€” tags each event with when Logstash processed it, and flags whether the JSON parsed cleanly:

Output β€” ships the event to a daily Elasticsearch index, and also prints it to the container’s console for debugging:

# =================================================================== # LOGSTASH PIPELINE CONFIGURATION # =================================================================== # =================================================================== # INPUT # =================================================================== input { file { # Read all application log files path => “/usr/share/logstash/logs/*.log” # Start reading from beginning for newly discovered files start_position => “beginning” # Persist file offsets so logs are not ingested repeatedly sincedb_path => “/usr/share/logstash/sincedb/sincedb_loyalty” # Poll files every second stat_interval => “1s” # Check for newly discovered log files discover_interval => 5 # Each application log line is one JSON object codec => json } } # =================================================================== # FILTER # =================================================================== filter { # ————————————————————— # Add Logstash processing metadata # ————————————————————— mutate { add_field => { “logstash_processed_at” => “%{@timestamp}” “pipeline_status” => “successfully_consumed_and_pushed” } } # ————————————————————— # Detect JSON parsing failures # ————————————————————— if “_jsonparsefailure” in [tags] { mutate { add_field => { “json_parse_status” => “FAILED” } } } else { mutate { add_field => { “json_parse_status” => “SUCCESS” } } } } # =================================================================== # OUTPUT # =================================================================== output { # ————————————————————— # Elasticsearch # ————————————————————— elasticsearch { hosts => [“http://elasticsearch:9200”] # Daily index index => “loyalty-program-%{+YYYY.MM.dd}” } # ————————————————————— # Console debugging # ————————————————————— stdout { codec => rubydebug { metadata => true } } }

sincedb

If you ever run multiple Logstash containers, or restart one, it needs a way to remember which log lines it has already consumed β€” otherwise every restart would re-push the entire file. That’s what sincedb is for: Logstash writes its own offset-tracking file there. Just create an empty sincedb folder inside elk-resource; Logstash creates the file itself.

Your elk-resource folder now looks like this:

elk-resource folder showing logs, logstash-pipeline and sincedb subfolders
elk-resource folder layout

Now run the Logstash container, mounting all three folders:

docker run -d --name logstash --user root --network elk-network -v "/mnt/e/CodeOps Trek Tutorial WorkSpace/ELK Tutorial/ELK-Implementation-Work Space/elk-resource/logstash-pipeline/logstash.conf:/usr/share/logstash/pipeline/logstash.conf" -v "/mnt/e/CodeOps Trek Tutorial WorkSpace/ELK Tutorial/ELK-Implementation-Work Space/elk-resource/logs:/usr/share/logstash/logs" -v "/mnt/e/CodeOps Trek Tutorial WorkSpace/ELK Tutorial/ELK-Implementation-Work Space/elk-resource/sincedb:/usr/share/logstash/sincedb" docker.elastic.co/logstash/logstash:8.13.4
  • --name β€” container name: logstash
  • --network β€” network it joins: elk-network
  • -v β€” three mounts: the pipeline config file, the logs folder, and the sincedb folder
  • last line β€” the image: logstash:8.13.4

Make sure you swap in the correct absolute path to your own elk-resource folder in each -v mount.

As soon as Logstash starts, it begins pulling logs and pushing them into the Elasticsearch index β€” creating the index itself if it doesn’t exist yet. To watch it happening:

docker exec -it logstash tail -f /usr/share/logstash/logs/application.log

You’ll see it actively pulling the logs. Head back to http://localhost:9200/_cat/indices?v and you’ll now see a fresh loyalty-program-* index. To pull the actual data back out: http://localhost:9200/loyalty-program-*/_search?pretty

With that, log collection and shipping into Elasticsearch is fully wired up.

Setup Kibana

With Elasticsearch holding the index and Logstash actively pushing data into it, we can now set up the last piece of this pipeline β€” Kibana.

docker run -d --name kibana --network elk-network -p 5601:5601 -e "ELASTICSEARCH_HOSTS=http://elasticsearch:9200" docker.elastic.co/kibana/kibana:8.13.4
  • --name β€” container name: kibana
  • --network β€” network it joins: elk-network
  • -p β€” port exposed to the host: 5601 (Kibana UI)
  • -e β€” tells Kibana where to find Elasticsearch, using the container name (elasticsearch) instead of localhost, since we’re inside the same Docker network
  • last line β€” the image: kibana:8.13.4

Give it a few seconds to boot up, then head to http://localhost:5601.

First Look at Kibana

The first time you open it, you’ll land on Kibana’s welcome/home page.

Kibana welcome home page with Observability, Security, and Analytics options after first-time Docker setup
Kibana’s welcome screen after running the container for the first time.

Before you can see any logs here, Kibana needs to know which Elasticsearch index to read from β€” that’s what a data view is for.

Create a Data View

Go to ☰ menu β†’ Stack Management β†’ Kibana β†’ Data Views, then:

  • Click Create data view
  • Name: anything you like (e.g. loyalty-logs)
  • Index pattern: loyalty-program-*
  • Timestamp field: pick @timestamp (Logstash adds this automatically) β€” or your own timestamp field if you’re using one
  • Click Save data view to Kibana

View Logs in Discover

Once the data view is saved, go to ☰ menu β†’ Analytics β†’ Discover. This opens a live view of every log document flowing into that index.

Kibana Discover tab showing loyalty-logs data view with log volume histogram and raw JSON documents
Live log stream in Kibana Discover, filtered by the loyalty-logs data view.

Expanding any log line opens a detail popup with the full document β€” every field, fully broken out, exactly as Logstash and your app enriched it.

That said, the default view (raw event.original JSON per row) isn’t the easiest to scan. You can make it far more useful by picking the fields you actually care about from the Available fields panel on the left, and adding them as columns.

Kibana Discover custom column layout showing serviceName, requestId, transactionType, customerId, and message fields
Structured logs displayed as readable columns instead of raw JSON.

In the view above, we’ve built a custom column layout with:

@timestamp, serviceName, instanceId, requestId, transactionType, customerId, message, payload

Now every log line reads like a proper structured record instead of a wall of JSON.

One thing you’ll notice: since multiple log lines for a single transaction are written within milliseconds of each other, they can appear slightly out of sequence when sorted purely by timestamp. This is expected behavior β€” it doesn’t affect analysis, since requestId still ties every related line together regardless of ordering.

Trace a Single Request

This is where the requestId field we added via MDC earlier really pays off. Search for a specific request ID in the KQL search bar, and Kibana filters down to just the log lines belonging to that one transaction β€” from “Initiating REDEEM transaction process” all the way to “REDEEM transaction completed successfully.”

Kibana Discover log entries filtered and highlighted by a single requestId showing a full REDEEM transaction trace
All log lines for one REDEEM transaction, traced end-to-end via requestId.

You can search by any field the same way β€” level: ERROR, customerId: 3, transactionType: REDEEM β€” and Kibana will filter live. From here, you can also set up alerts (e.g. notify on level: ERROR spikes) and build out full monitoring dashboards, but that’s a topic for another post.

And that’s the complete loop β€” application logs, shipped through Logstash, indexed in Elasticsearch, and searchable end-to-end in Kibana. This is how you setup ELK Stack in Docker.

Setup Loyalty Application on Your System

Want to try this yourself instead of just following along? You can spin up the entire pipeline β€” MySQL, the Loyalty Application, and the ELK Stack β€” on your own system in just a few steps.

Start by creating a folder on your system, set up the elk-network, and then set up Elasticsearch, Logstash, and Kibana exactly as covered above. Once that’s done, let’s add the database and the application itself.

Setup MySQL

Run the following command to spin up a MySQL container, attach it to elk-network, and automatically create the loyalty database:

docker run -d --name mysql-container --network elk-network -p 3306:3306 -e "MYSQL_ROOT_PASSWORD=root" -e "MYSQL_DATABASE=loyalty" mysql:8.0.45

This creates a MySQL container named mysql-container, attaches it to elk-network, and creates the loyalty database automatically.

To verify the database and tables, access the MySQL shell inside the container:

Verify MySQL Database
# Open a shell inside the MySQL container
docker exec -it mysql-container bash

# Log in to MySQL
mysql -uroot -proot

# Verify the database and tables
SHOW DATABASES;
USE loyalty;
SHOW TABLES;

Setup the Loyalty Application

With MySQL and ELK both up and attached to elk-network, let’s run the Loyalty Application itself:

docker run -it --name loyalty-program --network elk-network -p 8081:8081 -e "DB_HOST=mysql-container" -v "/mnt/e/CodeOps Trek Tutorial WorkSpace/ELK Tutorial/ELK-Implementation-Work Space/elk-resource/logs:/app/elk-resource/logs" codeopstrek/loyaltyprogram:v1.1

This command pulls and runs the Loyalty Application image, connects it to your MySQL container by name, and mounts your elk-resource/logs folder β€” the same one Logstash is already watching β€” so your logs flow into the same pipeline instantly.

You can grab the complete Postman collection for the Loyalty Management API from GitHub, import it, and start hitting the Customer and Transaction APIs right away.

Conclusion

Setting up the ELK Stack in Docker is, honestly, the easy part β€” a handful of containers, a shared network, and a few volume mounts, and you’re up and running in minutes. The real work, and the real value, lies upstream of ELK entirely: in how well your application generates its logs.

The better and more structured the logs you generate, the better you’re able to search, filter, debug, and monitor your application end-to-end. A request ID, a transaction type, a service name β€” these small additions are what turn Elasticsearch and Kibana from a glorified text search into a genuine observability platform. Get your logging right, and ELK does the rest.

ELK Stack Series

What is the ELK Stack? Master Elasticsearch, Logstash, and Kibana with Real-World Examples

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *