Ga naar inhoud

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) - recommended M1 (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.

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:

  1. Increase heap size (already configured with ES_JAVA_OPTS=-Xms1024m -Xmx1024m)
  2. Upgrade pod size to M1 (2GB RAM) or M2 (4GB RAM)
  3. 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:

curl -u elastic:password -s "https://your-endpoint.kinsta.app/_cluster/health?pretty"

Cleanup:

If the offending index is a leftover (e.g. from a local dev environment or a failed migration), delete it:

curl -X DELETE -u elastic:password "https://your-endpoint.kinsta.app/name-of-the-index"

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

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

curl -u elastic:password -X DELETE "https://your-endpoint.kinsta.app/test"

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:

  1. WordPress → ElasticPress → Sync
  2. Select "Delete all data and re-sync"
  3. Monitor progress in ElasticPress dashboard

Security Notes

  • Always use xpack.security.enabled=true for production
  • Use strong, unique passwords per Elasticsearch instance
  • The anonymous_health user can ONLY access /_cluster/health - no data access
  • All data operations require authentication with the elastic user
  • 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