Why centralize your logs on a VPS instead of staying with SSH
When an error hits multiple services simultaneously — a timeout on the application side, a 502 on nginx, an exception in a worker — no SSH tool lets you correlate these events without reading them one by one. docker compose logs -f app, then docker compose logs -f nginx, then docker compose logs -f worker: that's serial diagnosis, on a single server. On two VPS instances, the time doubles. On three, it triples.
Loki is an open source log aggregation system developed by Grafana Labs and listed in the CNCF catalog. Unlike Elasticsearch, which indexes the full content of every log line, Loki only indexes labels — lightweight metadata such as the container name, service name, or environment. The logs themselves are compressed and stored as-is. This design choice has a direct consequence: Loki runs comfortably on a 2 GB RAM VPS, where a basic ELK stack requires at least 8 to 16 GB to remain stable. For a developer or sysadmin managing multiple self-hosted applications without an enterprise APM budget, that is the difference between a feasible solution and an impractical one.
What this stack delivers concretely
- Unified search across all containers — a single LogQL query covers all services across all your VPS instances, without SSH or distributed
grep. - Temporal correlation — Grafana displays Loki (logs) and Prometheus (metrics) on the same time axis: you see the 502 error and the CPU spike that preceded it in a single view.
- No ingestion quota — you store as many logs as your disk allows, without a subscription or monthly limit imposed by a third party.
- Contained memory footprint — Loki consumes significantly less RAM than Elasticsearch at equivalent log volume, thanks to label-only indexing.
- Configurable retention — set retention duration per label (
chunk_retain_period,retention_period) according to your disk constraints and compliance requirements. - Native Grafana integration — Loki is a first-class data source in Grafana: no third-party plugin, no intermediate API.
- Alerts on log content — Grafana can trigger an alert when a regular expression matches in a Loki stream, without going through an external service.
- Data hosted on your infrastructure — logs stay on your VPS, without transiting through a cloud service.
Prerequisites before deploying
The Loki + Promtail + Grafana stack is lightweight, but it has its own requirements. Here is what you need before starting.
Recommended VPS resources: 2 vCPU and 2 GB of RAM are a reasonable minimum for single-server usage with a few dozen active containers. If you collect logs from multiple VPS instances or a large number of services, aim for 4 GB of RAM. Loki does not keep logs in memory: it compresses them and writes them to disk, which keeps the memory footprint stable over time. Plan for 20 to 50 GB of SSD storage depending on your log volume and retention period.
Required software: Docker and Docker Compose v2 installed on the VPS, a dedicated subdomain (logs.your-domain.com) pointing to the VPS IP address, and ports 3100 (Loki) and 3000 (Grafana) accessible internally. Port 3100 must not be exposed publicly — Loki has no native authentication layer, only Grafana is exposed via reverse proxy.
Step-by-step deployment
Create the directory structure
Connect to your VPS and create a dedicated directory: mkdir -p /opt/loki-stack/{loki,promtail} && cd /opt/loki-stack. This folder will hold the Loki and Promtail configuration files along with the docker-compose.yml.
Write the Loki configuration
Create /opt/loki-stack/loki/loki-config.yaml with the following content:
auth_enabled: false — disables multi-tenant authentication, sufficient for single-VPS usage.
server: { http_listen_port: 3100 }
ingester: { lifecycler: { address: 127.0.0.1, ring: { kvstore: { store: inmemory }, replication_factor: 1 } }, chunk_idle_period: 5m, chunk_retain_period: 30s }
schema_config: { configs: [ { from: 2020-10-24, store: boltdb-shipper, object_store: filesystem, schema: v11, index: { prefix: index_, period: 24h } } ] }
storage_config: { boltdb_shipper: { active_index_directory: /loki/boltdb-shipper-active, cache_location: /loki/boltdb-shipper-cache, shared_store: filesystem }, filesystem: { directory: /loki/chunks } }
limits_config: { retention_period: 720h }
Write the Promtail configuration
Create /opt/loki-stack/promtail/promtail-config.yaml. Promtail is the agent that collects logs from Docker containers and pushes them to Loki:
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: [ { 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_log_stream'], target_label: stream } ] } ]
This configuration uses Docker auto-discovery (docker_sd_configs), which detects every started container without manual intervention.
Write the docker-compose.yml file
Create /opt/loki-stack/docker-compose.yml:
version: '3.8'
services:
loki: image: grafana/loki:3.0.0 ports: ['127.0.0.1:3100:3100'] volumes: [loki-data:/loki, ./loki/loki-config.yaml:/etc/loki/local-config.yaml] command: -config.file=/etc/loki/local-config.yaml restart: unless-stopped
promtail: image: grafana/promtail:3.0.0 volumes: [/var/run/docker.sock:/var/run/docker.sock:ro, ./promtail/promtail-config.yaml:/etc/promtail/config.yml, /var/log:/var/log:ro] command: -config.file=/etc/promtail/config.yml restart: unless-stopped depends_on: [loki]
grafana: image: grafana/grafana:latest ports: ['127.0.0.1:3000:3000'] volumes: [grafana-data:/var/lib/grafana] environment: [GF_SECURITY_ADMIN_PASSWORD=change-me] restart: unless-stopped depends_on: [loki]
volumes: loki-data: grafana-data:
Note that Loki (3100) and Grafana (3000) are bound to 127.0.0.1 only — they are not directly accessible from outside.
Start the stack
From /opt/loki-stack, run: docker compose up -d. Wait 20 to 30 seconds then check that all three containers are running: docker compose ps. Then verify the Loki logs to confirm it started without errors: docker compose logs -f loki. You should see a line msg="Loki started" in the output.
Configure the reverse proxy for Grafana
Expose Grafana via nginx with a subdomain and TLS certificate. Create /etc/nginx/sites-available/grafana.conf:
server { listen 443 ssl; server_name logs.your-domain.com; ssl_certificate /etc/letsencrypt/live/logs.your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/logs.your-domain.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
Obtain the certificate with: certbot certonly --nginx -d logs.your-domain.com, then enable the site: ln -s /etc/nginx/sites-available/grafana.conf /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx.
Add Loki as a data source in Grafana
Open https://logs.your-domain.com in your browser. Log in with the admin username and the password you set in GF_SECURITY_ADMIN_PASSWORD. Go to Connections → Data sources → Add data source, choose Loki, and enter the URL: http://loki:3100. Click Save & test — you should see the message Data source connected and labels found.
Query your first logs
In Grafana, open the Explore tab and select the Loki source. Use the Log Browser to pick a container label and filter by container name. For example, to see all logs from the nginx container: {container="nginx"}. To filter on a string: {container="nginx"} |= "error". To count errors by container over the last hour: sum by (container) (count_over_time({container=~".+"} |= "error" [1h])).
Collect logs from a second VPS
To centralize logs from a second VPS into the same Loki instance, deploy only Promtail on that secondary VPS. In its configuration, replace the client URL with the internal address of your first VPS: url: http://<Loki-VPS-IP>:3100/loki/api/v1/push. Open port 3100 only between the two VPS instances (firewall or private network), never publicly. Add a static label to distinguish logs by server: static_configs: [ { labels: { host: vps2 } } ].
Post-install configuration: retention, backup, and alerts
Once the stack is running, three adjustments are important before considering the deployment complete.
Retention. The retention_period: 720h value in loki-config.yaml corresponds to 30 days. Adjust this parameter based on your available disk space (docker system df to check volume usage). Loki applies retention in the background with no noticeable performance impact.
Backup. The loki-data volume contains the BoltDB indexes and compressed chunks — it is the only element to back up. A daily snapshot of the Docker volume (docker run --rm -v loki-data:/data -v /backup:/backup alpine tar czf /backup/loki-$(date +%F).tar.gz /data) is sufficient for standard usage. Do not back up Grafana without its grafana-data volume (it contains your dashboards and alerts).
Alerts on log content. In Grafana, create a Grafana managed alert on a Loki query: for example, trigger a notification when the number of lines containing FATAL exceeds 0 over the last 5 minutes. Configure a notification channel (email, Slack, webhook) in Alerting → Contact points before creating the rule.
Loki exposes no native authentication mechanism on port 3100. If you need to expose the Loki API to an untrusted network (for remote Promtail agents, for example), place an nginx reverse proxy with client certificate authentication or basic auth upstream. Never leave port 3100 open on 0.0.0.0 on a production VPS. On the Grafana side, enable strong authentication: GF_AUTH_ANONYMOUS_ENABLED=false and GF_USERS_ALLOW_SIGN_UP=false in the container environment variables.
Troubleshooting: common errors and how to resolve them
Here are the most frequent issues encountered when deploying the Loki + Promtail + Grafana stack.
Common errors
msg="error creating ingester" err="context deadline exceeded"on Loki startup — the Loki container does not have write access to the mounted volume. Check the permissions of the directory corresponding to the Docker volume (docker inspect loki-datato find the actual path) and ensure the container user (UID 10001 for recent Grafana images) can write to it:chown -R 10001:10001 /path/to/the/volume.Data source connected and labels foundabsent in Grafana, replaced byconnection refused— Grafana cannot reach Loki. Verify that the URL entered in the data source ishttp://loki:3100(the Docker service name, notlocalhost) and that both containers are in the same Docker network (docker inspect loki-grafana-stack_default).- Promtail collects no logs,
docker compose logs -f promtailshowscomponent=discovery.docker msg="refreshing targets"in a loop with no progress — Promtail does not have access to the Docker socket. Verify that the volume/var/run/docker.sock:/var/run/docker.sock:rois declared in the Promtail service and that the socket exists on the host:ls -la /var/run/docker.sock. err="entry out of order for stream"in Loki logs — logs are arriving with out-of-order timestamps (a restarted container resending old logs, for example). Addmax_stream_label_count: 0underlimits_configandunordered_writes: trueunderingesterinloki-config.yamlto accept out-of-sequence entries.- Grafana shows
no dataon a valid LogQL query — first check the time range selected in the top right (the most common trap: range set tolast 5 minuteswhile Promtail has not yet collected recent logs). Then verify that the label used in your query actually exists:{container="nginx"}fails if the container is namednginx-1— use the Log Browser to explore available labels.
A dashboard to correlate logs and metrics
The main benefit of Loki in an existing Grafana environment is correlation with Prometheus metrics. If you already have a Prometheus source (see the article on VPS monitoring with Grafana and Prometheus), you can create a mixed dashboard: a row of metric panels (CPU, memory, HTTP request rate) at the top, and a Loki log panel filtered on the same service at the bottom. When a CPU spike appears at 2:37 PM, you immediately see which logs were emitted at that exact moment, without switching tools or running another SSH command. This type of correlation is the use case that justifies deploying both stacks rather than one or the other: Prometheus for "what can be measured", Loki for "what gets narrated".
Loki vs ELK vs SSH logs — which approach for which use case
| Criterion | SSH + docker logs | ELK Stack (Elastic) | Loki + Grafana |
|---|---|---|---|
| RAM required | None (no service) | 8–16 GB minimum per node | 2–4 GB for a standard VPS |
| Indexing | None | Full-text indexing (Elasticsearch) | Label-only indexing |
| Multi-server search | Impossible without scripting | Yes, natively | Yes, via multi-host Promtail |
| Correlation with metrics | Manual | With Kibana + APM (complex) | Native in Grafana |
| Software cost | None | Basic license free, features limited | Fully open source, no quota |
What you have just put into orbit
You now have a working log aggregation stack: Loki stores and indexes by labels, Promtail automatically collects streams from all your Docker containers, and Grafana provides a search and alerting interface. The stack runs entirely on your VPS, without a third-party service, without an ingestion quota, and without additional software cost. Natural next steps: connect this stack to your existing Prometheus monitoring for metrics/logs correlation, enable distributed tracing with Tempo if your applications emit OpenTelemetry traces, and explore Grafana alerts on log content to be notified before your users report a problem.