Single-node Elasticsearch on Docker, tuned for local development.
Stable, lightweight, and instantly usable by a host-local NestJS backend at
http://localhost:9200 — without slowing your machine down.
Tip
New to Elasticsearch, Docker, or NestJS? Start with the zero-assumptions Beginner's Tutorial — it explains every term and walks you through your first run step by step.
- Overview
- Why This Setup
- Architecture
- Requirements
- Quick Start
- Configuration Explained
- Verification
- NestJS Integration
- Performance & Stability
- Troubleshooting
- Project Structure
- FAQ
- License
This repository provides a peace-of-mind, development-grade Elasticsearch environment. It runs one Elasticsearch node in Docker and is intentionally not a cluster. The goal is a database that is:
- Reachable from a local (non-Docker) NestJS app via
http://localhost:9200 - Memory-bounded so your IDE, browser, and backend stay responsive
- Resilient — never enters
RED, auto-restarts, persists data across reboots
It is designed to live next to an already-running Dockerized Redis without interfering with it.
Running Elasticsearch on a developer laptop usually goes wrong in two ways:
- It eats all the RAM → the whole system lags.
- It crash-loops or goes RED → the backend can't connect.
This setup solves both by hard-capping memory (1 GB heap inside a 1.5 GB
container), locking memory to prevent swap, disabling unused features
(security, ML, Kibana, clustering), and gating health on green/yellow.
| Goal | How it's achieved |
|---|---|
NestJS connects via localhost |
Port 9200:9200 published to the host |
| System stays fast | mem_limit: 1536m + ES_JAVA_OPTS=-Xms1g -Xmx1g |
| CPU stays under control | cpus: 4.0 (half of 8 threads) + node.processors=4 |
| No swap thrash | bootstrap.memory_lock=true + memlock ulimit |
| Never RED | Single-node primaries; healthcheck on green/yellow; stop_grace_period: 60s for clean shutdown |
| No restart loops | restart: unless-stopped + vm.max_map_count preset |
| Not exposed on the network | 127.0.0.1:9200 loopback bind (security is off) |
| No disk creep over time | logging capped at 10m × 3 + nofile raised |
| Data survives restarts | Named esdata volume |
Note
A single-node cluster with default indices reports yellow, not green.
That is expected and healthy — replicas simply have nowhere to go on one
node. yellow is a success state here, never a failure.
┌────────────────────────────── Host Machine (Elementary OS 8) ────────────────────────────────┐
│ │
│ ┌──────────────────────────────┐ │
│ │ NestJS Application │ │
│ │ (Local Process) │ │
│ │ Runtime: Node.js │ │
│ └───────────────┬──────────────┘ │
│ │ HTTP Request │
│ │ http://localhost:9200 │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Elasticsearch Container │ │
│ │ Image: 8.12.0 │ │
│ │ │ │
│ │ • Mode: Single Node │ │
│ │ • Heap: 1GB (Xms=1g, Xmx=1g) │ │
│ │ • Memory Limit: 1.5GB │ │
│ │ • Port: 9200 │ │
│ └───────────────────────────┬──────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Persistent Volume │ │
│ │ esdata (NVMe SSD) │ │
│ └────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ Redis Container │ │
│ │ (Docker Service) │ │
│ │ │ │
│ │ • Max Memory: 128–200MB │ │
│ │ • Eviction: allkeys-lru │ │
│ │ • Port: 6379 │ │
│ └──────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
Because NestJS runs on the host, it always uses http://localhost:9200 —
never http://elasticsearch:9200 (that hostname only resolves inside the
Docker network).
| Tool | Version (tested) | Notes |
|---|---|---|
| Docker Engine | 24+ (29.x ok) | Must be able to run containers |
| Docker Compose | v2 (docker compose) |
Bundled with modern Docker |
| Linux kernel | any with vm.max_map_count |
Elementary OS 8 / Ubuntu-based |
| RAM | 16 GB recommended | Setup is tuned for this |
# 1. Clone
git clone https://github.com/programmerShinobi/elasticsearch-docker-dev-setup.git
cd elasticsearch-docker-dev-setup
# 2. Prepare the host (sets & persists vm.max_map_count=262144)
./scripts/setup-host.sh
# └─ or do it manually:
# sudo sysctl -w vm.max_map_count=262144
# echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
# 3. Start Elasticsearch
docker compose up -d
# 4. Wait until healthy, then verify
curl http://localhost:9200/_cluster/health?prettyOr with the bundled Makefile:
make setup # host prep
make up # start
make health # check cluster health
make logs # follow logs
make down # stop (keeps data)Brand new to all this? Read the Beginner's Tutorial first. Full step-by-step walkthrough: docs/SETUP.md
Every line of docker-compose.yml maps to a requirement:
| Setting | Value | Purpose |
|---|---|---|
image |
docker.elastic.co/elasticsearch/elasticsearch:8.12.0 |
Pinned version for reproducibility |
discovery.type |
single-node |
No clustering, no master election |
xpack.security.enabled |
false |
Dev mode → plain HTTP, no auth/TLS |
xpack.ml.enabled |
false |
Drops ML, saves memory |
ES_JAVA_OPTS |
-Xms1g -Xmx1g |
Fixed 1 GB heap (min == max) |
bootstrap.memory_lock |
true |
Locks heap in RAM → no swapping |
node.processors |
4 |
Sizes thread pools for the 4-CPU cap |
ulimits.memlock |
-1 / -1 |
Lets memory locking actually work |
ulimits.nofile |
65536 |
Avoids "too many open files" as indices grow |
mem_limit |
1536m |
Container can never exceed 1.5 GB |
cpus |
4.0 |
Caps ES at 4/8 logical CPUs → host stays responsive |
ports |
127.0.0.1:9200:9200 |
Loopback only — not exposed to LAN (security is off) |
restart |
unless-stopped |
Survives reboots / daemon restarts |
stop_grace_period |
60s |
Clean shutdown → no shard corruption / RED |
logging |
10m × 3 |
Caps container logs so they never fill the disk |
volumes |
esdata: |
Indices persist across down/up |
healthcheck |
green/yellow | Marks container healthy only when usable |
Deep dive on memory & responsiveness: docs/PERFORMANCE.md
After docker compose up -d, confirm the install is valid:
# 1. Container is up and healthy
docker compose ps
# 2. Service responds with cluster info (JSON)
curl http://localhost:9200
# 3. Cluster health — "status" must be "green" or "yellow"
curl http://localhost:9200/_cluster/health?pretty
# 4. Port 9200 is open on the host
ss -tlnp | grep 9200A healthy response from step 3 looks like:
{
"cluster_name" : "docker-cluster",
"status" : "yellow",
"number_of_nodes" : 1,
"active_primary_shards" : 1,
"unassigned_shards" : 0
}Complete verification checklist & pass/fail criteria: docs/VERIFICATION.md
Important
Scope: this repository ships the Elasticsearch infrastructure only —
the docker-compose.yml and its supporting tooling. The
examples/nestjs/ directory is reference material, not a
runnable application. It deliberately omits package.json, tsconfig.json,
and a bootstrap entrypoint. The files exist to document the one integration
detail that trips people up — the localhost vs elasticsearch host rule —
and to be copied into your NestJS project, where they compile and run.
Because NestJS runs as a normal host process (outside Docker), it always
reaches the published port over localhost:
node: 'http://localhost:9200' // ✅ correct — host-local process
node: 'http://elasticsearch:9200' // ❌ resolves only inside the Docker networkMinimal module wiring (env-driven, no auth — security is off in dev mode):
// search.module.ts
import { Module } from '@nestjs/common';
import { ElasticsearchModule } from '@nestjs/elasticsearch';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
ElasticsearchModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
// From .env: ELASTICSEARCH_NODE=http://localhost:9200
node: config.get<string>('ELASTICSEARCH_NODE', 'http://localhost:9200'),
}),
}),
],
exports: [ElasticsearchModule],
})
export class SearchModule {}What's in examples/nestjs/
| File | Responsibility |
|---|---|
search.module.ts |
Registers the client from ELASTICSEARCH_NODE (with retries & timeout). |
search.service.ts |
Performance-conscious wrapper — single-doc CRUD, bulk index/update/delete, count, paginated & streaming search (PIT + search_after), update/delete-by-query, and index admin. Designed to avoid N+1 loops and deep pagination. |
search.controller.ts |
Demo REST endpoints proving the host → localhost:9200 round trip. |
How to use it: install the deps, copy these files into your project, import
SearchModule. Step-by-step in examples/nestjs/README.md.
This setup is engineered to keep an i7-1185G7 / 16 GB laptop responsive:
- Memory ceiling: ES can never use more than 1.5 GB (1 GB heap + overhead).
- No swap: heap is locked into RAM, so the OS won't page it out.
- Single node: no inter-node chatter, minimal CPU at idle.
- Bounded shards: one default index = 1 primary shard, cheap to manage.
Leaves roughly 14 GB for your IDE, browser, NestJS, and Redis.
Tuning notes & what to change if you have less/more RAM: docs/PERFORMANCE.md
| Symptom | Likely cause | Fix |
|---|---|---|
| Container exits immediately | vm.max_map_count too low |
Run ./scripts/setup-host.sh |
curl: connection refused |
ES still booting (~30–60s) | Wait, watch docker compose logs -f |
Status stuck yellow |
Unassigned replicas (normal) | None needed — yellow is healthy here |
Status RED |
Disk full / corrupt data | See docs/TROUBLESHOOTING.md |
| NestJS can't connect | Used elasticsearch:9200 |
Use http://localhost:9200 |
| OOM / killed | Other heavy apps | Lower heap, see PERFORMANCE.md |
Full guide: docs/TROUBLESHOOTING.md
elasticsearch-docker-dev-setup/
├── docker-compose.yml # The single-node ES service (the core)
├── .env.example # ELASTICSEARCH_NODE for the NestJS side
├── Makefile # make setup | up | down | health | logs
├── scripts/
│ └── setup-host.sh # Sets & persists vm.max_map_count
├── examples/
│ └── nestjs/ # Reference snippets to copy into your NestJS app
├── docs/
│ ├── TUTORIAL.md # Zero-assumptions beginner walkthrough
│ ├── SETUP.md # Step-by-step install
│ ├── VERIFICATION.md # Pass/fail acceptance checks
│ ├── PERFORMANCE.md # Memory & responsiveness tuning
│ └── TROUBLESHOOTING.md # Common failures & fixes
├── LICENSE
└── README.md
Why is the status "yellow" and not "green"?
A single node can't hold replica shards (a replica must live on a different
node than its primary). Default indices request 1 replica, so it stays
unassigned and the cluster reports yellow. All your data is fully available.
This is the correct, healthy state for single-node dev.
Is it safe to use in production?
No. Security is disabled and there is no clustering/replication. This is a development setup only.
Will this touch my existing Redis container?
No. This Compose project only defines the elasticsearch service and its own
esdata volume. Redis is untouched.
How do I reset all data?
docker compose down -v (or make clean) removes the esdata volume.
Destructive — all indices are deleted.
MIT © 2026 programmerShinobi