Read this ElasticSearch overview for a quick theory lesson.

Overview

This page walks through deploying Elasticsearch in a single-node Docker container on the VPS, with persistent storage on a bind-mounted volume.

The cluster is exposed on 127.0.0.1:9200 (for local services like Kibana) and on the VPS private IP 10.0.0.5:9200 (for Logstash workers running on private VMs).

No public exposure.

This is what will be exposed, and to whom:

AddressReached fromPurpose
127.0.0.1:9200Same host (Kibana, local curl, etc.)Local services on the VPS
10.0.0.5:9200Other hosts on the private LAN (LogStash workers)LogStash workers writing to ElasticSearch
Public internetnothingES is never publicly addressable

Prerequisites

  • Docker CE installed via the official method:

    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
        -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
        https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
        | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
  • A user with sudo and the ability to run docker compose.

  • VPS reachable on a private network at a known IP (in this guide: 10.0.0.5).

Installation

Before the actual installation, here are some prerequisites to take care of.

Kernel settings

IMPORTANT

Elasticsearch uses memory-mapped files heavily and refuses to start if vm.max_map_count < 262144.

This must be set on the host, not in the container.

# Apply now
sudo sysctl -w vm.max_map_count=262144
 
# Make it persist across reboots
echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-elasticsearch.conf

Verify:

sysctl vm.max_map_count
# vm.max_map_count = 262144

Directory layout

sudo mkdir -p /opt/observability-logs/es-data
sudo chown -R 1000:1000 /opt/observability-logs/es-data
cd /opt/observability-logs

UID 1000 matches the elasticsearch user inside the official image: without this, the container can’t write to the bind-mounted data directory.

Generate the elastic superuser password

WARNING

Use hex-only passwords. Special characters like ! and $ are interpreted by bash!

echo "ELASTIC=$(openssl rand -hex 24)"

Copy the hex value into your password manager now (What? You don’t have one? Check this out immediately!) then write it into the .env using EOF (the quotes around 'EOF' stop bash from interpreting anything inside):

sudo tee /opt/observability-logs/.env > /dev/null <<'EOF'
ELASTIC_PASSWORD=<paste hex value here>
EOF
sudo chmod 600 /opt/observability-logs/.env

docker-compose.yml

Create /opt/observability-logs/docker-compose.yml:

A few choices worth calling out:

  • bootstrap.memory_lock=true + ulimits.memlock: -1 pins ElasticSearch JVM memory into RAM so it can’t be swapped out. ES strongly discourages swapping (latency spikes); locking memory is the cleanest fix.
  • ES_JAVA_OPTS=-Xms2g -Xmx2g sets both min and max heap to 2 GB. Min == Max is best practice for the JVM. Any real log workload needs 2 GB+ to avoid hitting the parent circuit breaker.
  • xpack.security.http.ssl.enabled=false keeps the HTTP API on plain HTTP. The private LAN is trusted in this lab, and 9200 is never publicly exposed. In a production cluster (or anywhere outside a trusted network), enable HTTP TLS.
  • Two ports lines bind the same container port to two distinct host addresses: the private LAN IP and 127.0.0.1. This makes 9200 reachable to Logstash workers (over the private LAN), and to Kibana / local curl (over localhost), but nothing else on the public internet sees it.

Start it and verify

cd /opt/observability-logs
sudo docker compose up -d elasticsearch

The first start pulls the ~1 GB image and warms up the cluster: give it ~60 seconds.

sudo docker compose logs -f elasticsearch
# ... [INFO ][o.e.n.Node] [elasticsearch] started

Now let’s verify the installation is working:

ELASTIC=$(sudo grep '^ELASTIC_PASSWORD=' /opt/observability-logs/.env | cut -d= -f2-)
 
# Cluster health — expect "yellow" (single-node, replicas can't be allocated)
curl -s -u "elastic:$ELASTIC" http://localhost:9200/_cluster/health | python3 -m json.tool

Expected:

{
    "cluster_name": "docker-cluster",
    "status": "yellow",
    "number_of_nodes": 1,
    "number_of_data_nodes": 1,
    ...
}

INFO

Yellow is fine here. It means primary shards are allocated but replicas can’t be (single-node can’t host replicas of its own data: that would defeat the point).

Auth sanity check:

curl -s -o /dev/null -w "%{http_code}\n" -u "elastic:wrong" http://localhost:9200/
# 401
 
curl -s -o /dev/null -w "%{http_code}\n" -u "elastic:$ELASTIC" http://localhost:9200/
# 200

ILM policy + index template

Logstash will write to daily indices logs-YYYY.MM.dd.

Without lifecycle management those indices accumulate forever and eventually fill the disk or hit the cluster shard limit.

Set up an ILM policy that deletes indices older than 14 days:

ELASTIC=$(sudo grep '^ELASTIC_PASSWORD=' /opt/observability-logs/.env | cut -d= -f2-)
 
# ILM policy: keep 14 days, then delete
curl -sX PUT -u "elastic:$ELASTIC" \
  -H "Content-Type: application/json" \
  http://localhost:9200/_ilm/policy/logs_policy \
  -d '{
    "policy": {
      "phases": {
        "hot": {
          "actions": {
            "rollover": { "max_age": "1d", "max_size": "10gb" }
          }
        },
        "delete": {
          "min_age": "14d",
          "actions": { "delete": {} }
        }
      }
    }
  }' && echo

And an index template that applies it to every logs-*:

# Index template — applies the policy to all logs-* indices
curl -sX PUT -u "elastic:$ELASTIC" \
  -H "Content-Type: application/json" \
  http://localhost:9200/_index_template/logs_template \
  -d '{
    "index_patterns": ["logs-*"],
    "template": {
      "settings": {
        "index.lifecycle.name": "logs_policy",
        "number_of_shards": 1,
        "number_of_replicas": 0
      }
    },
    "priority": 500
  }' && echo

Where to go next

  • Kibana: next in this series: the web UI.

  • Logstash setup: the worker pool that will write events into ES from the private LAN.