Self-hosting a search engine sounds intimidating. But Elasticsearch on Docker? That’s actually one of the cleaner self-hosting experiences out there, because Elastic ships a first-rate official image and the containerized setup sidesteps most of the dependency headaches you’d hit with a bare-metal install. Whether you’re building an app with full-text search, powering a log analytics pipeline, or just want to stop paying for a managed cloud search tier, this guide walks you through every step on a fresh Ubuntu VPS.
What Is Elasticsearch?
Elasticsearch is an open-source, distributed search and analytics engine built on top of Apache Lucene. Originally designed for full-text search, it’s grown into a general-purpose data store capable of handling logs, metrics, geospatial data, and structured documents at scale. Companies like GitHub, Netflix, and Uber rely on it for everything from code search to real-time anomaly detection.
At its core, Elasticsearch stores data as JSON documents inside indices. When you query those indices, you get back ranked results in milliseconds, even across billions of records. It’s also the “E” in the popular ELK Stack (Elasticsearch, Logstash, Kibana), which is a go-to toolkit for centralized log management.
Running it inside Docker on your VPS means you get a reproducible, isolated installation with a clean upgrade path. No system-level Java conflicts, no package manager quirks. Just a container that runs the same way every time.
Prerequisites
Before you dive in, make sure your server and local environment check these boxes.
| Requirement | Minimum | Recommended |
|---|---|---|
| CPU | 2 vCPUs | 4+ vCPUs |
| RAM | 4 GB | 8+ GB |
| Disk | 20 GB SSD | 50+ GB SSD |
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| Docker | 24.x | Latest stable |
| Docker Compose | v2.x | Latest stable |
| Open ports | 80, 443, 9200, 9300 | Same |
You’ll also need a non-root sudo user on the server, SSH access, and a registered domain name if you plan to expose Elasticsearch behind Nginx with SSL.
How to Install Elasticsearch on a VPS with Docker: Step-by-Step
Step 1: Update Your Server
Start with a clean slate. SSH into your server and run a full package update so you’re working from a stable base.
sudo apt update && sudo apt upgrade -y
Reboot if a kernel update landed.
sudo reboot
Step 2: Install Docker and Docker Compose
Docker’s official repository gives you the latest stable engine. Don’t use the version in Ubuntu’s default apt repository — it’s often several major versions behind.
First, install the prerequisite packages and add Docker’s GPG key.
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
Next, add the Docker apt repository.
echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Now install Docker Engine and the Compose plugin.
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add your user to the docker group so you can run Docker without sudo.
sudo usermod -aG docker $USER
newgrp docker
Verify the installation.
docker --version
docker compose version
Step 3: Tune the Virtual Memory Limit
Elasticsearch relies on memory-mapped files and needs the kernel’s vm.max_map_count set to at least 262144. The default on most Linux systems is far too low. Without this, Elasticsearch will refuse to start or crash shortly after launch.
sudo sysctl -w vm.max_map_count=262144
That change is temporary. Make it permanent across reboots by adding it to /etc/sysctl.conf.
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
Step 4: Create the Project Directory and Environment File
Keeping your configuration organized from the start saves headaches down the road. Create a dedicated directory for the project.
mkdir -p ~/elasticsearch-docker && cd ~/elasticsearch-docker
Create an .env file to store sensitive values and version pins. Never hardcode passwords directly in your Compose file.
cat > .env <<'EOF'
ELASTIC_VERSION=8.13.4
ELASTIC_PASSWORD=changeme_use_a_strong_password
KIBANA_PASSWORD=changeme_kibana_password
STACK_VERSION=8.13.4
CLUSTER_NAME=es-docker-cluster
LICENSE=basic
MEM_LIMIT=1073741824
EOF
Replace the placeholder passwords with strong, random strings. The MEM_LIMIT value is in bytes — 1073741824 equals 1 GB, which is safe for a 4 GB VPS. Double it on an 8 GB server.
Step 5: Write the Docker Compose File
The Compose file defines your Elasticsearch container and any supporting services. This configuration runs a single-node cluster, which is perfectly fine for development and moderate production workloads.
cat > docker-compose.yml <<'EOF'
version: "3.8"
services:
setup:
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
user: "0"
command: >
bash -c '
if [ x${ELASTIC_PASSWORD} == x ]; then
echo "Set the ELASTIC_PASSWORD environment variable in the .env file";
exit 1;
fi;
if [ ! -f config/certs/ca.zip ]; then
echo "Creating CA";
bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
unzip config/certs/ca.zip -d config/certs;
fi;
if [ ! -f config/certs/certs.zip ]; then
echo "Creating certs";
echo -ne \
"instances:\n"\
" - name: es01\n"\
" dns:\n"\
" - es01\n"\
" - localhost\n"\
" ip:\n"\
" - 127.0.0.1\n"\
> config/certs/instances.yml;
bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
unzip config/certs/certs.zip -d config/certs;
fi;
echo "Setting file permissions";
chown -R root:root config/certs;
find . -type d -exec chmod 750 \{\} \;;
find . -type f -exec chmod 640 \{\} \;;
echo "Waiting for Elasticsearch availability";
until curl -s --cacert config/certs/ca/ca.crt https://es01:9200 | grep -q "missing authentication credentials"; do sleep 30; done;
echo "Setting kibana_system password";
until curl -s -X POST --cacert config/certs/ca/ca.crt -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" https://es01:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done;
echo "All done!";
'
healthcheck:
test: ["CMD-SHELL", "[ -f config/certs/es01/es01.crt ]"]
interval: 1s
timeout: 5s
retries: 120
es01:
depends_on:
setup:
condition: service_healthy
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}
labels:
co.elastic.logs/module: elasticsearch
volumes:
- certs:/usr/share/elasticsearch/config/certs
- esdata01:/usr/share/elasticsearch/data
ports:
- 127.0.0.1:9200:9200
environment:
- node.name=es01
- cluster.name=${CLUSTER_NAME}
- cluster.initial_master_nodes=es01
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=true
- xpack.security.http.ssl.key=certs/es01/es01.key
- xpack.security.http.ssl.certificate=certs/es01/es01.crt
- xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.key=certs/es01/es01.key
- xpack.security.transport.ssl.certificate=certs/es01/es01.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.license.self_generated.type=${LICENSE}
mem_limit: ${MEM_LIMIT}
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test:
[
"CMD-SHELL",
"curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'",
]
interval: 10s
timeout: 10s
retries: 120
volumes:
certs:
driver: local
esdata01:
driver: local
EOF
A couple of things worth noting in this file. The port binding uses 127.0.0.1:9200:9200 rather than 0.0.0.0:9200:9200, which keeps Elasticsearch off the public internet. Traffic comes in through Nginx, not directly. The healthcheck on es01 depends on the setup service completing first, which prevents a common race condition where the node starts before its TLS certificates exist.
Step 6: Start the Stack
Bring everything up in detached mode. Docker Compose will pull the Elasticsearch image if it isn’t cached locally, then run the setup service before starting the main node.
docker compose up -d
The setup container takes 60–90 seconds to generate certificates and configure passwords. Watch its progress.
docker compose logs -f setup
When you see All done! in the logs, the certificates are in place. Check that the main node is healthy.
docker compose ps
The es01 service should show healthy in the status column. If it shows starting, give it another 30 seconds and check again.
Step 7: Verify Elasticsearch is Running
The node listens on localhost:9200 with HTTPS and requires authentication. Test it with curl, passing the CA certificate and the elastic superuser credentials.
docker compose exec es01 curl -s --cacert config/certs/ca/ca.crt \
-u elastic:${ELASTIC_PASSWORD} \
https://localhost:9200
You should get back a JSON response similar to this.
{
"name" : "es01",
"cluster_name" : "es-docker-cluster",
"cluster_uuid" : "...",
"version" : {
"number" : "8.13.4",
...
},
"tagline" : "You Know, for Search"
}
That tagline is your confirmation. Elasticsearch is alive and authenticated correctly.
Step 8: Install and Configure Nginx as a Reverse Proxy
Exposing Elasticsearch’s port directly to the internet is a serious security risk. Instead, you’ll put Nginx in front to handle public traffic, enforce authentication, and eventually terminate SSL.
sudo apt install -y nginx
Create a new Nginx server block for Elasticsearch. Replace your_domain.com with your actual domain.
sudo nano /etc/nginx/sites-available/elasticsearch
Paste in this configuration.
server {
listen 80;
server_name your_domain.com;
location / {
proxy_pass https://127.0.0.1:9200;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Pass Authorization header through
proxy_pass_header Authorization;
}
}
The proxy_ssl_verify off directive tells Nginx to trust the self-signed internal certificate between itself and the container. Your users still get the benefit of your public-facing SSL cert, which you’ll add in the next step.
Enable the site and test the configuration.
sudo ln -s /etc/nginx/sites-available/elasticsearch /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Step 9: Secure with SSL Using Certbot
Install Certbot and the Nginx plugin, then obtain a free Let’s Encrypt certificate.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain.com
Certbot will automatically modify your Nginx configuration to handle HTTPS and redirect HTTP traffic. When prompted, choose option 2 to redirect all HTTP requests to HTTPS.
Verify that automatic renewal works.
sudo certbot renew --dry-run
Step 10: Configure the Firewall
Lock down the firewall so only the necessary ports accept incoming connections. Port 9200 should stay closed to external traffic since Nginx handles that routing internally.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
Your output should show that ports 22, 80, and 443 are open, and nothing else.
Step 11: Enable Docker to Start on Boot
Without this step, your container won’t survive a server reboot. Enable the Docker service and configure your Compose stack with a restart policy.
sudo systemctl enable docker
The es01 service in the Compose file doesn’t include a restart policy yet. Add it now by editing docker-compose.yml and inserting restart: unless-stopped under the es01 service block, at the same indentation level as image:.
es01:
depends_on:
setup:
condition: service_healthy
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}
restart: unless-stopped # Add this line
...
Apply the change without downtime.
docker compose up -d --no-deps es01
Step 12: Create Your First Index and Test a Search Query
Now that the stack is running and secured, try creating an index and indexing a document to confirm end-to-end functionality.
# Create an index called "books"
curl -s -X PUT "https://your_domain.com/books" \
-u elastic:${ELASTIC_PASSWORD} \
-H "Content-Type: application/json"
# Index a document
curl -s -X POST "https://your_domain.com/books/_doc/1" \
-u elastic:${ELASTIC_PASSWORD} \
-H "Content-Type: application/json" \
-d '{"title": "The Pragmatic Programmer", "author": "David Thomas", "year": 1999}'
# Search for it
curl -s -X GET "https://your_domain.com/books/_search?q=pragmatic" \
-u elastic:${ELASTIC_PASSWORD}
The search response includes a hits array with your document inside. That’s Elasticsearch working as expected, end to end.
How to Update Elasticsearch
Elasticsearch follows a rolling upgrade model. In a single-node Docker setup, upgrading is straightforward: update the image tag, bring the container down, and bring it back up. Your index data persists in the named volume and survives the upgrade.
Edit your .env file and bump ELASTIC_VERSION to the new release.
nano .env
# Change ELASTIC_VERSION=8.13.4 to ELASTIC_VERSION=8.14.0 (or whatever is current)
STACK_VERSION=8.14.0
Pull the new image and recreate the container.
docker compose pull
docker compose up -d
Docker Compose detects the image change and restarts only the affected services. Verify the upgraded version after startup.
docker compose exec es01 curl -s --cacert config/certs/ca/ca.crt \
-u elastic:${ELASTIC_PASSWORD} \
https://localhost:9200 | grep number
Always check the official Elasticsearch migration guide before upgrading across major versions (for example, from 7.x to 8.x). Those upgrades involve breaking changes in security defaults, index compatibility, and API behavior that a simple image swap won’t handle automatically.
Troubleshooting Common Issues
Elasticsearch Container Exits Immediately with “max virtual memory” Error
This is almost always a vm.max_map_count problem. Run sysctl vm.max_map_count and check the output. If it’s below 262144, the fix is straightforward.
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
Then restart the container with docker compose restart es01.
Setup Service Exits Without Creating Certificates
Check the setup logs for the exact failure.
docker compose logs setup
A common culprit is a missing or empty ELASTIC_PASSWORD variable in .env. Make sure the file exists and contains no trailing whitespace around the values. You can also manually clean the certificates volume and restart fresh.
docker compose down -v
docker compose up -d
Warning: docker compose down -v deletes all volumes, including your index data. Only use this on a fresh install or when data loss is acceptable.
Nginx Returns a 502 Bad Gateway
A 502 almost always means Nginx can’t reach the upstream service. Confirm the container is healthy first.
docker compose ps
docker compose logs es01 --tail=50
If the container shows healthy, the issue is likely in the Nginx proxy configuration. Double-check the proxy_pass URL uses https://127.0.0.1:9200 and that proxy_ssl_verify off is present. Reload Nginx after any configuration changes with sudo systemctl reload nginx.
Elasticsearch is Running but Returns 401 Unauthorized
Starting from Elasticsearch 8.0, security is enabled by default and the cluster requires authentication. Confirm you’re sending the correct credentials.
source .env
echo $ELASTIC_PASSWORD
If you recently changed the password in .env without resetting it inside Elasticsearch, the container still uses the original password it was bootstrapped with. To change the elastic user password post-installation, use the API directly.
docker compose exec es01 curl -s -X POST --cacert config/certs/ca/ca.crt \
-u elastic:OLD_PASSWORD \
-H "Content-Type: application/json" \
https://localhost:9200/_security/user/elastic/_password \
-d '{"password": "NEW_PASSWORD"}'
High Memory Usage Causing Container OOM Kills
Elasticsearch’s JVM heap can grow aggressively. If the container gets killed by the Linux OOM killer, you’ll see 137 as the exit code in docker compose ps. Reduce the heap size by lowering MEM_LIMIT in your .env file and explicitly setting JVM options.
Add this to the environment section of es01 in your Compose file.
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
Adjust the heap values to match your available RAM, keeping in mind that the heap should never exceed half the container’s mem_limit. Restart the container to apply.
docker compose up -d --no-deps es01
What to Do Next
Your Elasticsearch node is up, secured, and accessible through Nginx. Here’s where things get interesting.
If you’re building a log pipeline or observability stack, add Kibana to your Compose file. The Elastic documentation provides a ready-made multi-service example that adds Kibana alongside the node you just configured. Logstash or Elastic Agent can then ship logs from your other services into Elasticsearch, and you’ll have a full ELK stack running on your own infrastructure for a fraction of the cost of a managed solution.
For application search, explore the official client libraries. Elastic ships first-party clients for PHP, Python, Go, Node.js, Ruby, and Java, all maintained in lock-step with the server. Connecting your web application to the cluster you built today is a matter of dropping in the client library, pointing it at your domain, and passing credentials.
Consider setting up index lifecycle management (ILM) policies if you’re storing time-series data like logs or metrics. ILM automatically moves indices through hot, warm, cold, and delete phases based on age or size, keeping your disk usage from spiraling out of control over time.
Finally, snapshot your index data to an S3-compatible bucket or a local path on a regular schedule. A single-node cluster has no replica shards, so if the node’s storage dies, your data goes with it. Regular snapshots are the minimum viable backup strategy for a setup like this.
Conclusion
Getting Elasticsearch running on a VPS with Docker is genuinely one of the more approachable self-hosting projects. The official image handles most of the hard work, Docker Compose ties the services together cleanly, and Nginx gives you a secure, browser-friendly endpoint without exposing the cluster’s native port to the world. From here, your cluster is ready to index documents, power full-text search, or serve as the foundation of a full observability stack — all on infrastructure you control.


