Guide: Centralize logs with Fluentd and Elasticsearch for PHP apps on a VPS

By · Published on · Updated on · 9 min read

Green network cables plugged into a rack panel in a data center
Close-up of cables in a rack, representing infrastructure for log collection and processing. Photo: Brett Sayles / Pexels

This guide shows, hands-on, how to centralize logs for PHP applications on a VPS using Fluentd and Elasticsearch. You’ll standardize JSON logs, bring up the stack with Docker Compose, configure Fluentd routing and set safe retention in Elasticsearch.

Architecture: what we'll build

The solution collects logs from PHP apps and services (Nginx/Apache), forwards them to Fluentd and indexes them in Elasticsearch. Optionally, use Kibana for queries and dashboards. Everything runs on a VPS, isolated with Docker and an internal network.

The focus is reliability and simplicity: structured JSON logs at the source, disk buffering in Fluentd, indices with automatic retention in Elasticsearch, and ports exposed only on the VPS localhost interface.

  • PHP applications write logs in JSON (Monolog/PSR-3).
  • Fluentd tails files and enriches metadata (host, app, env).
  • Elasticsearch stores and indexes; Kibana visualizes.
  • Internal Docker network for services; external ports bound to 127.0.0.1.
Log source How to collect in Fluentd Notes
PHP app (file) Tail /var/log/php/app.log (one JSON event per line) Ensure log rotation and consistent timestamps.
Nginx/Apache Tail access/error.log + parser (regex/grok) Unify timezone and include request_id if possible.
Docker containers Tail container files or use the fluentd driver Driver is convenient but increases coupling; evaluate.

Prerequisites and capacity planning

You need a VPS with Docker/Compose installed, ports 9200 and 5601 reachable only locally, and enough disk for your log volume. Elasticsearch consumes RAM and I/O; tune the heap according to the machine.

Quick checklist:

- Docker and Compose installed; if needed, follow the guide How to install Docker and Docker Compose on Linux.

- Firewall configured; see the VPS Linux security checklist.

- Dedicated data directories for Elasticsearch and Fluentd.

- An index retention plan to avoid filling the disk.

Important: never expose port 9200 to the public internet without authentication and TLS. Bind to 127.0.0.1 and use an SSH tunnel or an authenticated proxy for access.

# Requisito do Elasticsearch
sudo sysctl -w vm.max_map_count=262144
echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-elastic.conf

# Estrutura de diretórios (ajuste caminho conforme sua preferência)
mkdir -p ~/observability/elasticsearch/{data,logs}
mkdir -p ~/observability/fluentd/{state,buffer}
mkdir -p ~/observability/logs

Standardize PHP logs as JSON with context (PSR-3/Monolog)

Centralization works better with structured logs. Use Monolog with JsonFormatter and include fields such as level, message, datetime, request_id, user_id and app. This simplifies searches and correlation. See the structured JSON logs and correlation guide for modelling best practices.

Example basic configuration with Monolog for a standalone PHP project. In frameworks like Laravel, adjust config/logging.php (see docs at laravel.com).

<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;

$logDir = __DIR__ . '/logs';
if (!is_dir($logDir)) mkdir($logDir, 0775, true);

$logger = new Logger('my-php-app');
$handler = new StreamHandler($logDir . '/app.log', Logger::INFO);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);

// Contexto útil para correlação
$context = [
    'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)),
    'user_id' => $currentUserId ?? null,
    'env' => getenv('APP_ENV') ?: 'prod',
];

$logger->info('User logged in', $context);
$logger->error('Payment failed', $context + ['order_id' => 123]);

Bring up Elasticsearch and Kibana with Docker Compose

We'll start a single-node Elasticsearch and Kibana for visualization. Bind ports only on 127.0.0.1. Set the ELASTIC_PASSWORD shell variable before running. Adjust ES_JAVA_OPTS (-Xms/-Xmx) to match your RAM.

For remote access, use an SSH tunnel or an authenticated proxy. Do not expose 9200/5601 publicly.

version: "3.8"
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD:-changeme}
      - xpack.security.enabled=true
      - xpack.security.transport.ssl.enabled=true
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - ./elasticsearch/data:/usr/share/elasticsearch/data
      - ./elasticsearch/logs:/usr/share/elasticsearch/logs
    ports:
      - "127.0.0.1:9200:9200"
    networks:
      - observability

  kibana:
    image: docker.elastic.co/kibana/kibana:8.13.4
    container_name: kibana
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
      - ELASTICSEARCH_USERNAME=elastic
      - ELASTICSEARCH_PASSWORD=${ELASTIC_PASSWORD:-changeme}
    ports:
      - "127.0.0.1:5601:5601"
    depends_on:
      - elasticsearch
    networks:
      - observability

networks:
  observability:
    name: observability

Configure Fluentd: inputs, enrichment and Elasticsearch output

Fluentd will tail the log files, enrich events with host/app/env and send them to Elasticsearch with a disk buffer. Adjust paths and tag names to your environment.

In the example we use the PHP app.log and Nginx access.log. The index prefix will be php-logs, producing daily indices.

# fluent.conf
# Input: app PHP em JSON (uma linha por evento)
<source>
  @type tail
  path /var/log/php/app.log
  pos_file /fluentd/state/app.log.pos
  tag php.app
  read_from_head true
  <parse>
    @type json
    time_key datetime
    time_type string
  </parse>
</source>

# Input: Nginx access log (texto); adapte regex conforme seu formato
<source>
  @type tail
  path /var/log/nginx/access.log
  pos_file /fluentd/state/nginx.access.pos
  tag nginx.access
  read_from_head true
  <parse>
    @type regexp
    expression /^(?<remote>\S+) - (?<user>\S+) \[(?<time>[^\]]+)\] "(?<method>\S+) (?<path>\S+) (?<proto>[^"]+)" (?<status>\d{3}) (?<bytes>\d+)/
    time_key time
    time_format %d/%b/%Y:%H:%M:%S %z
  </parse>
</source>

# Enriquecimento: host/env/app
<filter **>
  @type record_transformer
  enable_ruby true
  <record>
    host ${Socket.gethostname}
    env ${ENV['env'] || 'prod'}
    app my-php-app
  </record>
</filter>

# Saída: Elasticsearch (índices diários com prefixo)
<match **>
  @type elasticsearch
  host elasticsearch
  port 9200
  scheme http
  user elastic
  password ${ENV['ELASTIC_PASSWORD']}
  logstash_format true
  logstash_prefix php-logs
  include_tag_key true
  reconnect_on_error true
  reload_connections false
  request_timeout 30s

  <buffer>
    @type file
    path /fluentd/buffer
    flush_mode interval
    flush_interval 5s
    chunk_limit_size 8m
    queued_chunks_limit_size 256
    retry_forever true
    retry_max_interval 30
  </buffer>
</match>

Orchestrate Fluentd with Docker (Elasticsearch plugin)

The official Fluentd image needs the elasticsearch plugin installed. Create a small Dockerfile to install the plugin and a Compose service to run Fluentd with volumes and a shared network with Elasticsearch.

Mount the app log file into the Fluentd container as read-only. Expose the Forwarder port (24224) only if you expect logs from other hosts.

# fluentd/Dockerfile
FROM fluent/fluentd:v1.16-1
USER root
RUN gem install fluent-plugin-elasticsearch --no-document \
 && mkdir -p /fluentd/state /fluentd/buffer \
 && chown -R fluent:fluent /fluentd
COPY fluent.conf /fluentd/etc/fluent.conf
USER fluent

---
# docker-compose.fluentd.yml
version: "3.8"
services:
  fluentd:
    build: ./fluentd
    container_name: fluentd
    environment:
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD:-changeme}
      - env=prod
    volumes:
      - ./fluentd/fluent.conf:/fluentd/etc/fluent.conf:ro
      - ./logs/app.log:/var/log/php/app.log:ro
      - ./fluentd/state:/fluentd/state
      - ./fluentd/buffer:/fluentd/buffer
    depends_on:
      - elasticsearch
    networks:
      - observability
    # Descomente se for receber de outros hosts/containers via forward
    # ports:
    #   - "127.0.0.1:24224:24224"

networks:
  observability:
    external: true

Indices and retention: template + ILM in Elasticsearch

Without retention your disk will fill up. Use an ILM policy to delete old indices and an index template to apply the policy to new indices with the php-logs-* prefix. This pairs well with the daily index format Fluentd generates.

Adjust min_age to your needs and consider hot→warm phases before deletion in larger environments.

# Crie uma policy de deleção após 14 dias
curl -u elastic:${ELASTIC_PASSWORD} -X PUT \
  'http://localhost:9200/_ilm/policy/php-logs-14d' \
  -H 'Content-Type: application/json' -d '{
  "policy": {"phases": {"hot": {"actions": {}}, "delete": {"min_age": "14d", "actions": {"delete": {}}}}}
}'

# Template para índices que começam com php-logs-
curl -u elastic:${ELASTIC_PASSWORD} -X PUT \
  'http://localhost:9200/_index_template/php-logs' \
  -H 'Content-Type: application/json' -d '{
  "index_patterns": ["php-logs-*-*", "php-logs-*"],
  "template": {
    "settings": {
      "index.lifecycle.name": "php-logs-14d",
      "number_of_shards": 1,
      "number_of_replicas": 0
    },
    "mappings": {
      "dynamic": true,
      "properties": {
        "datetime": {"type": "date", "format": "strict_date_optional_time||epoch_millis"},
        "level": {"type": "keyword"},
        "app": {"type": "keyword"},
        "host": {"type": "keyword"},
        "message": {"type": "text"}
      }
    }
  },
  "priority": 500
}'

How to confirm it worked and next steps

Generate a test event in the app log file and check the Fluentd logs. Then search for the event in Elasticsearch. Use Kibana for dashboards and analysis. For metrics, complement with the guide to Prometheus & Grafana with Docker Compose on a VPS.

For distributed tracing in PHP, see Distributed tracing with OpenTelemetry in PHP.

# 1) Gere um log de teste (ajuste caminho)
echo '{"datetime":"2024-05-01T12:00:00Z","level":"INFO","message":"hello from php","request_id":"abc123"}' \
  >> ~/observability/logs/app.log

# 2) Veja o Fluentd entregando
docker compose -f docker-compose.fluentd.yml logs -f fluentd

# 3) Busque no Elasticsearch
curl -u elastic:${ELASTIC_PASSWORD} \
  'http://localhost:9200/php-logs-*/_search?q=message:hello&pretty'

Common troubleshooting

Some recurring issues and quick fixes to keep ingestion stable. Check permissions, formats and buffers. Tune Elasticsearch heap and Fluentd chunk_limit_size according to volume.

  • Fluentd not reading file: verify path, permissions and read_from_head.
  • Wrong timestamp: standardize timezone in the app and in the parser; prefer ISO 8601 with Z.
  • Index mapped wrong type: fix the field in the app and recreate the index/template.
  • Disk filling: reduce ILM retention, compact logs and increase rotation in the app.
  • Elasticsearch slowness: tune ES_JAVA_OPTS, use shards=1 and replicas=0 for single-node.
  • Nginx parser failing: adapt the regex to your real log format.

Frequently asked questions

Can I use Fluent Bit instead of Fluentd?

Yes. Fluent Bit is lighter and recommended as an agent on hosts or containers. This guide uses Fluentd for plugin flexibility and buffering. The overall architecture and Elasticsearch delivery remain the same.

How much RAM does Elasticsearch need for a single node?

It depends on volume and query complexity. For small environments, size the heap (ES_JAVA_OPTS) to a portion of available RAM and monitor. Start modest and increase as ingestion and queries grow.

How do I secure access to Elasticsearch and Kibana?

Use Elasticsearch's built-in authentication, bind to 127.0.0.1 and access via SSH tunnel or an authenticated TLS proxy. For VPS firewall and hardening, follow the VPS Linux security checklist.

Is JSON required for PHP logs?

No, but it's recommended. JSON reduces ambiguity, improves parsing and speeds up searches. If you keep free text logs, you'll need reliable parsers and accept loss of structured detail in queries.

How to handle multiple VPSs or microservices?

Run a Fluentd instance on each VPS and send to a central Elasticsearch. Use service and environment tags, and unify indices by prefix. Ensure local buffers and a retention policy to avoid data loss during network failures.

Is Kibana mandatory? Can I use another tool?

Kibana is not mandatory, but it greatly simplifies exploration and dashboards. You can query via the Elasticsearch API or integrate other compatible tools. The key is keeping indices accessible and well-mapped.

Want to speed up your log implementation?

If you need this guide adapted to your PHP stack and VPS, let's discuss next steps and common risks.

Talk about your setup

See also