Elasticsearch Setup on Sevalla/Kinsta¶
Initial Setup¶
Create a new application in Sevalla with the following Docker image: elasticsearch:9.2.4 (or desired version). Do not deploy yet.
Environment Variables¶
Add the following environment variables:
ELASTIC_PASSWORD=your-strong-password-here
ES_JAVA_OPTS=-Xms1024m -Xmx1024m
ES_LOG_STYLE=console
ES_SETTING_BOOTSTRAP_MEMORY__LOCK=true
ES_SETTING_DISCOVERY_TYPE=single-node
ES_SETTING_NODE_STORE_ALLOW__MMAP=false
ES_SETTING_XPACK_SECURITY_ENABLED=true
ES_SETTING_XPACK_SECURITY_AUTHC_ANONYMOUS_USERNAME=anonymous_health
ES_SETTING_XPACK_SECURITY_AUTHC_ANONYMOUS_ROLES=monitoring
Resource Configuration¶
- Pod Size: Minimum
S1(0.5 CPU / 1 GB RAM) - recommendedM1(0.5 CPU / 2 GB RAM) for production - Disk: Attach a disk to your Web process with mount path:
/usr/share/elasticsearch/data - Web Port:
9200
Now you can deploy the application.
Post-Deployment: Create Monitoring Role¶
After deployment, create the monitoring role that allows anonymous health checks:
curl -X POST -u elastic:your-password "https://your-endpoint.kinsta.app/_security/role/monitoring" \
-H "Content-Type: application/json" \
-d '{"cluster":["monitor"],"indices":[]}'
This role allows the anonymous user to access /_cluster/health but nothing else.
Configure Health Checks (Optional but Recommended)¶
In Sevalla, configure the readiness probe for zero-downtime deployments:
Readiness Probe:
- Path: /_cluster/health?local=true
- Port: 9200
- Initial delay: 30 seconds
- Period: 10 seconds
- Timeout: 5 seconds
- Success threshold: 1
- Failure threshold: 3
Liveness Probe:
- Path: /_cluster/health?local=true
- Port: 9200
- Initial delay: 60 seconds
- Period: 30 seconds
- Timeout: 5 seconds
- Success threshold: 1
- Failure threshold: 3
The health endpoint is accessible without authentication but provides no access to your data.
WordPress ElasticPress Configuration¶
Install Content Filter¶
Add this filter to your theme's functions.php to prevent document size issues:
// Prevent ElasticPress from indexing overly large documents
add_filter('ep_post_sync_args', function($post_args, $post_id) {
// Remove post_content_filtered - often contains large HTML with srcsets
unset($post_args['post_content_filtered']);
// Optional: Limit post_content to 50KB if needed
if (isset($post_args['post_content']) && strlen($post_args['post_content']) > 50000) {
$post_args['post_content'] = substr($post_args['post_content'], 0, 50000);
}
return $post_args;
}, 10, 2);
ElasticPress Settings¶
In WordPress → ElasticPress → Settings:
- Host:
https://your-elasticsearch-endpoint.kinsta.app - Username:
elastic - Password:
[your ELASTIC_PASSWORD]
Indexing Configuration¶
For optimal indexing performance with limited resources:
// In wp-config.php - reduce batch size to prevent memory issues
define('EP_SYNC_CHUNK_LIMIT', 50); // Default is 350
Security Verification¶
After setup, verify that your data is properly secured:
Test Health Endpoint (Should Work)¶
# Health check works without authentication
curl "https://your-endpoint.kinsta.app/_cluster/health"
# Expected: 200 OK with cluster status
Test Data Access (Should Fail)¶
# Reading indices should fail without auth
curl "https://your-endpoint.kinsta.app/_cat/indices?v"
# Expected: 403 Forbidden
# Searching data should fail without auth
curl "https://your-endpoint.kinsta.app/_search"
# Expected: 403 Forbidden
# Writing data should fail without auth
curl -X POST "https://your-endpoint.kinsta.app/test/_doc/1" \
-H 'Content-Type: application/json' \
-d '{"test": "data"}'
# Expected: 403 Forbidden
Test Authenticated Access (Should Work)¶
# With credentials, everything should work
curl -u elastic:password "https://your-endpoint.kinsta.app/_cat/indices?v"
# Expected: 200 OK with indices list
Troubleshooting¶
Issue: "Forbidden" Errors During Indexing¶
Symptoms: - ElasticPress shows "Forbidden" after each batch - Many posts fail to index (e.g., "Number of posts index errors: 2712") - Only a small percentage of posts are successfully indexed
Root Cause:
Documents are too large due to post_content_filtered containing full HTML with image srcsets and embedded content.
Solution:
Add the content filter mentioned above to remove post_content_filtered from indexed documents.
Issue: High Memory Usage (96%+ RAM)¶
Symptoms: - RAM constantly at 96%+ - Heap percentage climbing above 70% - Slow indexing or timeouts
Solutions:
- Increase heap size (already configured with
ES_JAVA_OPTS=-Xms1024m -Xmx1024m) - Upgrade pod size to M1 (2GB RAM) or M2 (4GB RAM)
- Reduce batch size with
EP_SYNC_CHUNK_LIMIT
Note: Linux showing 96% RAM usage is normal - it uses all available memory for caching. Focus on heap percentage instead.
Issue: Health Check Failing¶
Symptoms: - Readiness probe shows failures in Sevalla - Container keeps restarting
Check:
# Verify health endpoint works
curl "https://your-endpoint.kinsta.app/_cluster/health"
# Verify monitoring role exists
curl -u elastic:password "https://your-endpoint.kinsta.app/_security/role/monitoring"
Solution: Ensure you ran the monitoring role creation command after deployment.
Issue: Indexing Completely Fails¶
Check:
# Monitor heap and RAM during indexing
curl -u elastic:password "https://your-endpoint.kinsta.app/_cat/nodes?v&h=heap.percent,ram.percent"
# Check indexed document count
curl -u elastic:password "https://your-endpoint.kinsta.app/_cat/indices?v&h=index,docs.count,store.size"
# Test bulk indexing manually
curl -u elastic:password -X POST "https://your-endpoint.kinsta.app/_bulk" \
-H 'Content-Type: application/x-ndjson' \
--data-binary @- << EOF
{"index":{"_index":"test","_id":"1"}}
{"title":"Test"}
EOF
Issue: Cluster status turns "yellow" after creating a new index¶
Symptoms:
- BetterStack alerts that the cluster dropped from green to yellow
- _cluster/health shows "unassigned_shards": 5 (or a multiple of 5)
- "unassigned_primary_shards": 0 — all primary shards are active
Root Cause:
A new index was created with number_of_replicas: 1 (the Elasticsearch default). On a single-node setup, replica shards can never be assigned — they are not allowed to live on the same node as their primary. This is not a data error or risk; all data remains fully accessible.
Identify the offending index:
curl -u elastic:password -s "https://your-endpoint.kinsta.app/_cat/indices?v&h=index,status,pri,rep,unassign.shards"
Look for an index with rep: 1 while all others show rep: 0. A common source is a local development environment (e.g. Lando) creating an index with the wrong prefix or replica setting that then gets synced to production.
Check exactly which shards are unassigned:
curl -u elastic:password -s "https://your-endpoint.kinsta.app/_cat/shards?v&h=index,shard,prirep,state,unassigned.reason" | grep UNASSIGNED
Solution:
Set replicas to 0 for all indices:
curl -X PUT -u elastic:password "https://your-endpoint.kinsta.app/_all/_settings" \
-H "Content-Type: application/json" \
-d '{"index": {"number_of_replicas": 0}}'
The cluster status will return to green immediately. Verify:
Cleanup:
If the offending index is a leftover (e.g. from a local dev environment or a failed migration), delete it:
Multi-Site Strategy¶
Option A: Shared Elasticsearch (Cost-Effective)¶
One Elasticsearch instance serving multiple WordPress sites with separate indices:
- Site 1: site1-post-1
- Site 2: site2-post-1
Pros: Lower cost, efficient resource usage
Cons: Shared resources, single point of failure
Option B: Separate Elasticsearch per Site (Recommended)¶
Each WordPress site has its own Elasticsearch instance.
Pros: - Complete isolation - Independent scaling - No cross-site performance impact - Separate credentials per site
Cons: Higher cost (€20/month per instance)
Recommendation: Start with separate instances on M1. You can downgrade to H1 (Hobby tier) for low-traffic sites or consolidate later if needed.
Naming Convention¶
Recommended subdomain structure: {client}.es.lemone.network
Examples:
- familieoverdekook.es.lemone.network
- leukerecepten.es.lemone.network
Or with short codes:
- fotdk.es.lemone.network (Familie Over De Kook)
- lr.es.lemone.network (Leuke Recepten)
Resource Sizing Guide¶
| Pod Size | CPU | RAM | Monthly Cost | Recommended For |
|---|---|---|---|---|
| H1 | 0.3 | 0.3GB | €5 | Testing only |
| S1 | 0.5 | 1GB | €10 | Small sites (<1000 posts) |
| M1 | 0.5 | 2GB | €20 | Production (up to ~3000 posts) |
| M2 | 1 | 4GB | €70 | Multiple sites or large indexes |
Minimum for production: M1 (2GB RAM)
Heap allocation: Always set to 1GB with M1 (-Xms1024m -Xmx1024m)
Maintenance¶
Cleanup Test Indices¶
Monitor Health¶
# Cluster health (no auth needed)
curl "https://your-endpoint.kinsta.app/_cluster/health?pretty"
# Node stats (requires auth)
curl -u elastic:password "https://your-endpoint.kinsta.app/_cat/nodes?v&h=heap.percent,ram.percent"
# Indices overview (requires auth)
curl -u elastic:password "https://your-endpoint.kinsta.app/_cat/indices?v&h=index,docs.count,store.size"
Set number of replicas to 0¶
To make sure the cluster health endpoint returns "status": "green" switch the number of replicas to 0.
curl -X PUT -u elastic:password "https://your-endpoint.kinsta.app/_all/_settings" \
-H "Content-Type: application/json" \
-d '{
"index": {
"number_of_replicas": 0
}
}'
Re-index After Code Changes¶
After adding the content filter or making other changes:
- WordPress → ElasticPress → Sync
- Select "Delete all data and re-sync"
- Monitor progress in ElasticPress dashboard
Security Notes¶
- Always use
xpack.security.enabled=truefor production - Use strong, unique passwords per Elasticsearch instance
- The
anonymous_healthuser can ONLY access/_cluster/health- no data access - All data operations require authentication with the
elasticuser - Credentials are stored in WordPress database (ElasticPress settings)
- Sevalla/Kinsta provides HTTPS by default via Cloudflare
- IP whitelisting may not be available - rely on password authentication
What the Anonymous User Can Access¶
✅ Allowed:
- /_cluster/health - Health status only
❌ Blocked:
- Reading indices (/_cat/indices)
- Searching data (/_search)
- Reading documents (/{index}/_doc/{id})
- Writing data (any POST/PUT/DELETE)
- Cluster settings (/_cluster/settings)
- Security APIs (/_security/*)
- All other endpoints