Why self-host Elasticsearch on your own VPS
Elasticsearch shines where simple search no longer suffices: configurable BM25 scoring, synonyms, custom linguistic analyzers (French, Arabic ICU), geo-queries, and a full ELK stack to centralize your application logs. Elastic Cloud and OpenSearch Service offerings quickly become costly once indexed volumes exceed a few gigabytes, with egress billing on top. On your VPS, you decide the deployed version, active plugins, retention duration and snapshot policy. It is also the only way to keep sensitive data — application logs, customer indexes — strictly within your own infrastructure, without dependency on a cloud tier.
On the licensing front, Elasticsearch became open source again in August 2024: since version 8.16, Elastic distributes the code under the AGPLv3 license (OSI-approved), alongside the Elastic License and SSPL. This return to open source changes the calculation for teams that had switched to OpenSearch in 2021. Current production versions are 8.19.x (long-term 8 branch) and 9.x (main branch since 2025).
What you gain by self-hosting Elasticsearch
- Advanced full-text search: BM25 scoring, synonyms, custom linguistic analyzers (FR, AR ICU)
- Complex aggregations and facets for e-commerce, BI or log centralization
- Full ELK stack (Logstash, Beats, Kibana) with no software surcharge
- Full control of the version, plugins and index lifecycle policies (ILM)
- No billing per indexed volume or network egress fees
- Automated snapshots to your own S3-compatible object storage via SLM
- Sensitive data kept within your own infrastructure, under your sole jurisdiction
Precise prerequisites before launching the first container
Minimum RAM: 4 GB for a test environment, 8 GB (2-4 vCPU) for a light production instance, 16 GB as soon as you add Kibana or index several million documents. The key parameter is the JVM heap: set -Xms and -Xmx to 50% of available RAM, without exceeding 31 GB (beyond that, the JVM switches to a less efficient pointer compression mode; the exact limit is 31 GB with G1GC, not 32 GB). On an 8 GB RAM VPS, use -Xms4g -Xmx4g. Always set -Xms equal to -Xmx: a heap that is under-allocated at startup and then extended during operation causes long GC pauses.
On the network side, Elasticsearch uses two ports: 9200 (HTTP, REST API) and 9300 (inter-node transport). Never expose port 9200 directly on the public interface — this is the primary source of compromise seen on unsecured instances. On the storage side, an NVMe SSD is recommended: Elasticsearch performs many random read operations on Lucene segments; a magnetic disk or low-end SSD will saturate quickly on large indexes. Also plan for Docker and Docker Compose, and an es.yourdomain.com subdomain pointed at the VPS.
Step-by-step deployment
Prepare the Linux kernel
Before launching the container, apply two mandatory system settings. First, increase the memory-mapped zone limit:
sysctl -w vm.max_map_count=262144. Persist this setting by addingvm.max_map_count=262144to/etc/sysctl.conf— without it, Elasticsearch refuses to start with amax virtual memory areas vm.max_map_count [65530] is too lowerror. Then, disable swap on the VPS (swapoff -aand comment out the swap line in/etc/fstab), or configurebootstrap.memory_lock=trueinelasticsearch.ymlso the JVM is never paged to disk, which would catastrophically degrade performance.Write the docker-compose.yml file
Create a working directory, then a
docker-compose.ymlfile with the Elasticsearch service: imagedocker.elastic.co/elasticsearch/elasticsearch:8.19.4, environment variableES_JAVA_OPTS=-Xms4g -Xmx4g(adapt to the VPS), a named volume mounted on/usr/share/elasticsearch/data, and the port 9200 bound to127.0.0.1only (127.0.0.1:9200:9200). Also adddiscovery.type=single-nodefor a single-node deployment. Never publish0.0.0.0:9200:9200in production.Enable xpack.security and start
In
elasticsearch.yml, verify thatxpack.security.enabled: trueandxpack.security.http.ssl.enabled: trueare active. Since version 8.x, security is enabled by default, but a legacy configuration file may disable it explicitly. Start the cluster:docker compose up -d. On first startup, wait 2 to 3 minutes — initialization of system indexes (.security-*,.kibana_*) takes time. Check the logs:docker compose logs -f elasticsearch.Create users and retrieve the elastic password
Once the container is started, reset the
elasticsuperuser password:docker exec -it elasticsearch bin/elasticsearch-reset-password -u elastic. Store this password in a secrets manager. Then create thekibana_systemsystem user if you add Kibana:docker exec -it elasticsearch bin/elasticsearch-users useradd kibana_system -r kibana_system. This account must never be used for application queries: create dedicated users per application, with minimum necessary roles.Verify the cluster with curl
Test the connection from the VPS (not from outside):
curl -u elastic:<PASSWORD> https://localhost:9200 --cacert /usr/share/elasticsearch/config/certs/http_ca.crt. A JSON response withcluster_nameandstatus: greenoryellowconfirms the cluster is operational. Ayellowstatus on a single-node cluster is normal: shard replicas cannot be allocated without a second node.Expose via HTTPS reverse proxy with Nginx
Install Nginx on the VPS and configure a virtual host for
es.yourdomain.com. The reverse proxy forwards requests tohttps://127.0.0.1:9200and presents a Let's Encrypt certificate to the client. Add Nginx basic authentication as an additional protection layer if the API must be accessible from outside. Only forward routes necessary for your application — avoid exposing/_cat/*or/_cluster/*publicly.
xpack security: TLS, roles and network isolation
Elasticsearch's xpack security covers three layers. Inter-node TLS (xpack.security.transport.ssl.enabled: true) encrypts traffic between nodes on port 9300 — essential as soon as a second node joins the cluster. HTTP TLS (xpack.security.http.ssl.enabled: true) encrypts port 9200; without it, passwords transit in clear even on a private network. Role control: Elasticsearch provides predefined roles (read, write, monitor, kibana_system, logstash_writer). Assign the minimum required role to each application: a service that only reads one index does not need the superuser role. Avoid using the elastic account in production — reserve it for initial administration. Last point: the network.host parameter in elasticsearch.yml. Its default value is _local_ (loopback only). Switching to 0.0.0.0 to listen on all interfaces without having configured xpack security exposes your cluster to the entire internet.
Harden network access with UFW
After verifying that Elasticsearch listens only on 127.0.0.1, lock down the firewall: ufw deny 9200/tcp and ufw deny 9300/tcp. Only the Nginx reverse proxy (port 443) should be accessible. If multiple nodes communicate with each other, explicitly allow node IPs on port 9300 (ufw allow from <NODE_2_IP> to any port 9300), and block everything else. A ufw status after configuration gives you the exact view of what is open.
Index Lifecycle Management (ILM): managing data lifespan
ILM automates the lifecycle of your indexes based on age or size criteria, preventing disk saturation and keeping queries fast on recent data. A typical ILM policy breaks down into four phases:
Hot phase: the index receives new data. Configure automatic rollover to create a new index once it reaches a certain size (max_size: 50gb) or age (max_age: 7d). Hot indexes live on your fastest NVMe SSDs.
Warm phase: the index is no longer written to but remains queried. Elasticsearch reduces replicas to 1 and performs a force-merge of Lucene segments (forcemerge: max_num_segments: 1) — this reduces memory used by open segments and speeds up reads.
Cold phase: rarely queried data. Replicas drop to 0 and the index can be mounted read-only from object storage (searchable snapshots), eliminating replication overhead.
Delete phase: automatic removal after the defined retention period (example: 90 days for application logs).
Create the policy via the REST API and attach it to an index template so it applies automatically to all new indexes:
PUT _ilm/policy/logs-policy
{
"policy": {
"phases": {
"hot": { "actions": { "rollover": { "max_age": "7d", "max_size": "50gb" } } },
"warm": { "min_age": "7d", "actions": { "forcemerge": { "max_num_segments": 1 }, "shrink": { "number_of_shards": 1 } } },
"cold": { "min_age": "30d", "actions": { "freeze": {} } },
"delete": { "min_age": "90d", "actions": { "delete": {} } }
}
}
}On a VPS where disk space is at a premium, ILM is what prevents your logs from saturating the SSD and keeps queries fast on hot data.
Snapshots to S3: backup and disaster recovery
Elasticsearch snapshots allow you to back up the complete state of your indexes to S3-compatible object storage. Combine them with a Snapshot Lifecycle Policy (SLM) to automate backups and their retention, without manual intervention.
1. Install the S3 plugin and configure the repository:
docker exec -it elasticsearch bin/elasticsearch-plugin install repository-s3Then declare the access keys in the Elasticsearch keystore (never in plain text in elasticsearch.yml):
docker exec -it elasticsearch bin/elasticsearch-keystore add s3.client.default.access_key
docker exec -it elasticsearch bin/elasticsearch-keystore add s3.client.default.secret_keyRegister the repository:
PUT _snapshot/s3-backup
{
"type": "s3",
"settings": {
"bucket": "my-elasticsearch-bucket",
"region": "eu-west-1",
"base_path": "snapshots/production"
}
}2. Create the Snapshot Lifecycle Policy:
PUT _slm/policy/daily-snapshots
{
"schedule": "0 30 2 * * ?",
"name": "<daily-snap-{now/d}>",
"repository": "s3-backup",
"config": { "indices": ["*"], "ignore_unavailable": true },
"retention": { "expire_after": "30d", "min_count": 5, "max_count": 50 }
}This policy triggers a snapshot every day at 2:30 AM, automatically names each snapshot with the date, and purges snapshots older than 30 days while keeping a minimum of 5 versions. Check snapshot status with GET _slm/policy/daily-snapshots and test restore from a staging environment before you need it.
JVM heap monitoring: detecting OOM before it happens
On a VPS, the Linux kernel OOM killer can terminate the Elasticsearch process without warning. Proactive JVM heap monitoring prevents 80% of production incidents.
Metrics to monitor via the _nodes/stats API:
- jvm.mem.heap_used_percent: trigger an alert above 75% on average, and a critical alert above 85% over 5 minutes. Sustained saturation at 90%+ indicates a memory leak or undersized heap.
- jvm.gc.collectors.old.collection_count and collection_time_in_millis: a rapid increase in old-gen (major GC) count and times > 2 s per collection signal that the JVM is spending more time collecting than working.
Prometheus + Grafana integration (Beats stack):
Add metricbeat to your Compose to collect Elasticsearch metrics and push them to Prometheus or directly into Elasticsearch for visualization in Kibana. The official image docker.elastic.co/beats/metricbeat:8.19.4 is preconfigured with an Elasticsearch module.
Operational rules:
1. If heap_used_percent exceeds 75% in steady state, increase the heap (or VPS RAM) before experiencing the first OOM.
2. Do not increase the heap beyond 31 GB — past this threshold, the garbage collector disables compressed object pointers (compressed oops) and the JVM consumes more memory per object, which is counterproductive.
3. Regularly verify that bootstrap.memory_lock: true is active with GET _nodes?filter_path=**.mlockall: a false value indicates swap is still active on the VPS.
Kibana: index exploration and dashboards (optional)
Kibana is the official graphical interface for Elasticsearch to explore indexes, build dashboards and configure alerts. It is not required for programmatic use (REST API), but it simplifies managing ILM, SLM and indexes in general.
Add Kibana to the same Compose file, connecting it to the internal Docker network:
kibana:
image: docker.elastic.co/kibana/kibana:8.19.4
environment:
- ELASTICSEARCH_HOSTS=https://elasticsearch:9200
- ELASTICSEARCH_USERNAME=kibana_system
- ELASTICSEARCH_PASSWORD=<KIBANA_SYSTEM_PASSWORD>
- ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=/usr/share/kibana/config/certs/http_ca.crt
volumes:
- /path/to/http_ca.crt:/usr/share/kibana/config/certs/http_ca.crt:ro
depends_on:
- elasticsearchExpose Kibana behind an Nginx reverse proxy on kibana.yourdomain.com with authentication. Never expose Kibana directly on the internet without authentication: it provides full cluster management access. From Kibana, access Stack Management → Index Lifecycle Policies and Snapshot and Restore to visually manage ILM and SLM, without writing manual API calls.
Troubleshooting: the 5 most common startup errors
1. OOM Killer kills the Elasticsearch process. Symptom: the container stops without an error message in logs, dmesg | grep -i killed reveals a Killed process. Cause: the -Xmx heap is too high for available RAM, or other processes are saturating memory. Fix: reduce -Xmx to 50% of actual free RAM (max 31 GB), and monitor memory consumption with docker stats.
2. max_map_count too low. Symptom: Elasticsearch refuses to start with the error max virtual memory areas vm.max_map_count [65530] is too low. Fix: sysctl -w vm.max_map_count=262144 then add vm.max_map_count=262144 to /etc/sysctl.conf.
3. Permission denied on /usr/share/elasticsearch/data. Symptom: AccessDeniedException error appears in logs when mounting the volume. Cause: the host directory belongs to root but the container runs with UID 1000 (user elasticsearch). Fix: chown -R 1000:1000 <host_volume_path> before running docker compose up.
4. Connection refused on port 9200. Symptom: curl localhost:9200 returns Connection refused. Frequent cause: network.host is misconfigured in elasticsearch.yml (value _site_ or an IP that does not match the Docker interface). On a single-node Docker cluster, leave network.host at its default value (_local_) and access via 127.0.0.1:9200 from the container or host. Also check that the container is running: docker ps.
5. Slow startup: normal on first initialization. Symptom: the cluster takes 2 to 3 minutes to respond on first launch. This is not a failure. Elasticsearch initializes system indexes (.security-7, .kibana_1, default mappings). Wait until logs show mode [basic], reason [security is enabled] or Cluster health status changed from [RED] to [GREEN] before sending queries.
What if you want a fully open source alternative?
OpenSearch is the community fork of Elasticsearch, born in 2021 when Elastic changed its license to SSPL (not OSI-approved). OpenSearch maintains an Apache 2.0 license, offers a largely compatible REST API, and includes advanced security features in its free distribution (role-based access control, audit logging, encryption at rest). Since Elastic reintroduced AGPLv3 in August 2024 with version 8.16, both projects are once again under OSI-approved licenses — the choice now depends more on ecosystem (plugins, integrations, commercial support) than on license constraints. Docker deployment follows the same pattern, with the opensearchproject/opensearch image instead.