Loki + Promtail + Grafana on a VPS: Centralized Log Aggregation Without Elasticsearch

Loki + Promtail + Grafana on a VPS: Centralized Log Aggregation Without Elasticsearch

Loki is Grafana Labs’ log aggregation system — like Prometheus but for logs. Unlike Elasticsearch (which indexes all log content and requires 4–16 GB RAM), Loki only indexes log metadata (labels) and compresses raw log text, making it 10× cheaper to run. Paired with Promtail (log shipper) and Grafana (visualization), the PLG stack provides centralized log aggregation for all services on a VPS with sub-200 MB RAM overhead.

PLG Stack vs ELK Stack

Factor Loki (PLG) Elasticsearch (ELK)
RAM requirement ~100–200 MB 4–8 GB minimum
Index approach Label-based metadata only Full-text search index
Query language LogQL Lucene / KQL
Grafana integration Native (same company) Plugin required
Cost to self-host Low (any VPS) High (4+ GB RAM)
Full-text search Regex over chunks Fast full-text index
Best for DevOps, infrastructure logs Application search, analytics

Step 1: Docker Compose Setup

<code">mkdir -p /opt/loki/{config,data} && cd /opt/loki
nano docker-compose.yml
<code">version: '3.8'

services:
  loki:
    image: grafana/loki:latest
    container_name: loki
    restart: always
    ports:
      - "127.0.0.1:3100:3100"
    command: -config.file=/etc/loki/config.yml
    volumes:
      - ./config/loki.yml:/etc/loki/config.yml:ro
      - loki_data:/loki

  promtail:
    image: grafana/promtail:latest
    container_name: promtail
    restart: always
    command: -config.file=/etc/promtail/config.yml
    volumes:
      - ./config/promtail.yml:/etc/promtail/config.yml:ro
      - /var/log:/var/log:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    depends_on:
      - loki

  grafana:
    image: grafana/grafana:latest
    container_name: grafana-logs
    restart: always
    ports:
      - "127.0.0.1:3001:3000"
    environment:
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
      GF_AUTH_ANONYMOUS_ENABLED: "false"
    volumes:
      - grafana_data:/var/lib/grafana
    depends_on:
      - loki

volumes:
  loki_data:
  grafana_data:

Step 2: Loki Configuration

<code">nano config/loki.yml
<code">auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096

common:
  instance_addr: 127.0.0.1
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

limits_config:
  retention_period: 30d       # Keep 30 days of logs
  max_query_series: 10000
  ingestion_rate_mb: 16
  ingestion_burst_size_mb: 32

compactor:
  working_directory: /loki/compactor
  retention_enabled: true

Step 3: Promtail Configuration

<code">nano config/promtail.yml
<code">server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  # System logs
  - job_name: system
    static_configs:
      - targets: [localhost]
        labels:
          job: syslog
          host: YOUR_HOSTNAME
          __path__: /var/log/syslog

  # Nginx access logs
  - job_name: nginx
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx
          host: YOUR_HOSTNAME
          type: access
          __path__: /var/log/nginx/access.log
    pipeline_stages:
      - regex:
          expression: '^(?P[\w\.]+) .* \[(?P[^\]]+)\] "(?P\w+) (?P[^ ]+) [^"]+" (?P\d+) (?P\d+)'
      - labels:
          method:
          status:

  # Nginx error logs
  - job_name: nginx_errors
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx
          type: error
          host: YOUR_HOSTNAME
          __path__: /var/log/nginx/error.log

  # Docker container logs
  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: container
      - source_labels: ['__meta_docker_container_image']
        target_label: image
<code">echo "GRAFANA_PASSWORD=StrongGrafanaPassword!" > .env
chmod 600 .env
docker compose up -d
docker compose logs -f loki

Step 4: Nginx for Grafana

<code">sudo nano /etc/nginx/sites-available/grafana-logs
<code">server {
    listen 443 ssl http2;
    server_name logs.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/logs.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/logs.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3001;
        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;
    }
}
<code">sudo certbot --nginx -d logs.yourdomain.com
sudo ln -s /etc/nginx/sites-available/grafana-logs /etc/nginx/sites-enabled/
sudo systemctl reload nginx

Step 5: Connect Loki to Grafana

  1. Login to https://logs.yourdomain.com
  2. Connections → Data Sources → Add → Loki
  3. URL: http://loki:3100
  4. Save & Test → “Data source connected”

Step 6: LogQL Queries

<code"># Basic log stream (all nginx logs)
{job="nginx"}

# Filter by label (HTTP 5xx errors)
{job="nginx", status="5xx"}

# Filter by content (grep equivalent)
{job="nginx"} |= "ERROR"

# Regex filter
{job="nginx"} |~ "POST /api/.*" | status = "500"

# Rate query (requests per second over time)
rate({job="nginx"}[5m])

# Error rate (5xx per minute)
sum(rate({job="nginx", status=~"5.."}[1m]))

# Count log lines per container
sum by (container) (count_over_time({job="docker"}[1h]))

# Top 10 IP addresses in Nginx logs
topk(10, sum by (remote_addr) (count_over_time({job="nginx"}[1h])))

Step 7: Build Log Dashboard

  1. Dashboards → New Dashboard → Add Panel
  2. Select Loki as data source
  3. Use “Logs” visualization for raw log streams
  4. Use “Time series” for rate queries (rate({job="nginx"}[5m]))
  5. Use “Stat” for total error count (count_over_time({job="nginx"} |= "ERROR" [24h]))

Save as “VPS Log Overview” dashboard — add panels for: Nginx request rate, Error log stream, Docker container logs, Systemd service failures.

Getting Started

The PLG stack (Loki + Promtail + Grafana) uses 200–400 MB total RAM — it runs alongside production services on any Ubuntu VPS at VPS.DO. Loki’s 30-day retention with filesystem storage uses approximately 1–5 GB disk for a typical VPS generating moderate log volume. The label-based indexing means storage scales with label cardinality, not log volume — no need to manage massive Elasticsearch indices.

Conclusion

The Loki + Promtail + Grafana stack provides centralized log aggregation at a fraction of the resource cost of Elasticsearch. Promtail automatically discovers Docker containers and tails log files, Loki stores logs with efficient compression and label-based indexing, and Grafana provides LogQL-powered dashboards for real-time log exploration. For VPS operators who want centralized visibility into all services without the 4 GB RAM Elasticsearch tax, the PLG stack is the practical choice.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!