Grafana Loki is a log aggregation system that indexes only a small set of labels for each log stream and stores the log lines themselves as compressed chunks. That design keeps storage and memory requirements far below full-text search engines, at the cost of relying on labels plus filtering at query time. In this tutorial you will install Loki 3 on Ubuntu 24.04 from Grafana's official APT repository, configure it as a single binary with local storage and a 30-day retention policy, send the server's journald logs to it with Grafana Alloy, and query them with LogQL.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS.
  • A non-root user with sudo privileges and UFW enabled with SSH allowed.
  • Enough free disk for your log volume. As a rough guide, Loki stores logs compressed at about a tenth of their raw size.
  • curl and jq installed (sudo apt install -y curl jq).

This guide covers the Loki server. To add Grafana dashboards, log parsing pipelines and alerts on top of it, follow Grafana Loki and Promtail complete setup afterwards.

Step 1 - Adding the Grafana APT repository

Grafana Labs publishes Loki, Alloy and Grafana as Debian packages in a signed repository. Install the tools needed to fetch the signing key:

sudo apt update
sudo apt install -y gpg wget

Download the key into /etc/apt/keyrings and add the repository, restricted to that key:

sudo mkdir -p /etc/apt/keyrings
wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/grafana.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt update

Confirm that apt sees the Loki package from the new repository:

apt-cache policy loki
loki:
  Installed: (none)
  Candidate: 3.x.x
  Version table:
     3.x.x 500
        500 https://apt.grafana.com stable/main amd64 Packages

Step 2 - Installing Loki

Install the package:

sudo apt install -y loki

The package creates a loki system user, installs the binary at /usr/bin/loki, a sample configuration at /etc/loki/config.yml and a loki systemd unit that reads that file. The sample configuration stores data under /tmp, which is cleared on reboot, so you will replace it in the next step.

Create a persistent data directory owned by the service user:

sudo mkdir -p /var/lib/loki
sudo chown loki:loki /var/lib/loki

Step 3 - Configuring storage, schema and retention

Back up the sample file and open the configuration:

sudo cp /etc/loki/config.yml /etc/loki/config.yml.orig
sudo nano /etc/loki/config.yml

Replace its content with the following single-node configuration:

auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: info

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

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

limits_config:
  retention_period: 720h

compactor:
  working_directory: /var/lib/loki/compactor
  retention_enabled: true
  retention_delete_delay: 2h
  delete_request_store: filesystem

analytics:
  reporting_enabled: false

What each block does:

  • auth_enabled: false runs Loki in single-tenant mode. Clients do not need to send an X-Scope-OrgID header.
  • common sets shared paths for every component, and inmemory for the hash ring, which is correct for a single instance.
  • schema_config uses the tsdb index and schema v13, the current recommended combination. The from date must be in the past on a new installation. Never edit an existing schema entry; to change it later you add a new entry with a future from date.
  • limits_config.retention_period: 720h keeps logs for 30 days.
  • The compactor merges index files and, with retention_enabled, actually deletes chunks older than the retention period. Without it, retention_period has no effect.
  • analytics.reporting_enabled: false disables anonymous usage reporting to Grafana Labs.

Check the configuration before starting the service:

sudo -u loki /usr/bin/loki -config.file=/etc/loki/config.yml -verify-config
level=info ... msg="config is valid"

Step 4 - Starting Loki

Enable and restart the service so it loads the new file:

sudo systemctl enable loki
sudo systemctl restart loki
sudo systemctl status loki
● loki.service - Loki service
     Loaded: loaded (/usr/lib/systemd/system/loki.service; enabled; preset: enabled)
     Active: active (running)

Loki takes around 15 seconds after startup to become ready. Poll the readiness endpoint:

curl -s http://localhost:3100/ready
ready

If it answers Ingester not ready: waiting for 15s after being ready, wait a few seconds and try again.

Push a test line through the HTTP API. Timestamps are Unix epoch in nanoseconds, which date +%s%N produces:

curl -s -X POST http://localhost:3100/loki/api/v1/push \
  -H "Content-Type: application/json" \
  --data-raw "{\"streams\":[{\"stream\":{\"job\":\"test\"},\"values\":[[\"$(date +%s%N)\",\"hello from curl\"]]}]}"

A successful push returns HTTP 204 with an empty body. Read it back with a LogQL query over the last hour, which is the default range of query_range:

curl -s -G http://localhost:3100/loki/api/v1/query_range --data-urlencode 'query={job="test"}' | jq -r '.data.result[].values[][1]'
hello from curl

Step 5 - Shipping the system journal with Grafana Alloy

Loki only stores what clients push to it. Grafana Alloy is the collector Grafana recommends for Loki; it replaces Promtail, which reached end of life in 2026 and no longer receives updates. Install it from the same repository:

sudo apt install -y alloy

Alloy runs as the alloy user. Add it to the systemd-journal group so it can read the journal, and to adm so it can read files in /var/log later:

sudo usermod -aG systemd-journal,adm alloy

Open the Alloy configuration:

sudo nano /etc/alloy/config.alloy

Replace its content with this pipeline, which reads the journal, turns the systemd unit name into a unit label, and pushes everything to Loki:

loki.relabel "journal" {
  forward_to = []

  rule {
    source_labels = ["__journal__systemd_unit"]
    target_label  = "unit"
  }
  rule {
    source_labels = ["__journal_priority_keyword"]
    target_label  = "level"
  }
}

loki.source.journal "system" {
  max_age       = "12h"
  relabel_rules = loki.relabel.journal.rules
  labels        = { job = "systemd-journal", host = constants.hostname }
  forward_to    = [loki.write.local.receiver]
}

loki.write "local" {
  endpoint {
    url = "http://127.0.0.1:3100/loki/api/v1/push"
  }
}

The __journal_* fields are only available during relabeling and are dropped afterwards, so only the labels you copy (unit, level) plus the static job and host labels end up in Loki. Keep labels to values with low cardinality like these; never turn request IDs, user IDs or IP addresses into labels.

Enable Alloy and restart it:

sudo systemctl enable alloy
sudo systemctl restart alloy
sudo journalctl -u alloy -n 20 --no-pager

The log should show the components starting without level=error lines. After a few seconds, list the labels Loki has received:

curl -s http://localhost:3100/loki/api/v1/labels | jq
{
  "status": "success",
  "data": [
    "host",
    "job",
    "level",
    "service_name",
    "unit"
  ]
}

Loki adds service_name automatically, based on the other labels, for use in Grafana's log exploration views.

Step 6 - Querying logs with LogQL

A LogQL query always starts with a stream selector in braces, which uses the index to pick streams by label, followed by optional filters applied to the log lines. Define a small helper to run queries from the shell:

logq() { curl -s -G http://localhost:3100/loki/api/v1/query_range --data-urlencode "query=$1" --data-urlencode "limit=${2:-20}" | jq -r '.data.result[].values[][1]'; }

Show the latest SSH log lines:

logq '{job="systemd-journal", unit="ssh.service"}'

Keep only failed login attempts with a line filter. |= means "contains", != "does not contain", and |~ applies a regular expression:

logq '{unit="ssh.service"} |= "Failed password"'
logq '{job="systemd-journal"} |~ "(?i)error|fail"' 50

Metric queries turn logs into numbers. This one counts log lines per unit over the last 5 minutes and uses the instant query endpoint:

curl -s -G http://localhost:3100/loki/api/v1/query \
  --data-urlencode 'query=sum by (unit) (count_over_time({job="systemd-journal"}[5m]))' \
  | jq -r '.data.result[] | "\(.value[1])\t\(.metric.unit)"' | sort -rn | head
42	cron.service
18	ssh.service
7	alloy.service

The same queries work unchanged in Grafana's Explore view once you add Loki as a data source.

Step 7 - Receiving logs from other servers

To centralize logs, install Alloy on each server with the same configuration and change the loki.write URL to http://your_loki_server_ip:3100/loki/api/v1/push.

Loki has no authentication with auth_enabled: false, so never expose port 3100 to the internet. Allow it only from the servers that ship logs, one rule per source IP:

sudo ufw allow from client_server_ip to any port 3100 proto tcp

For clients outside a private network, put Loki behind a reverse proxy with TLS and basic authentication, and add a basic_auth block to the endpoint in each client's loki.write.

Troubleshooting

systemctl status loki shows it restarting in a loop. Run sudo journalctl -u loki -n 50 --no-pager. The usual causes are a YAML indentation error, or permission denied on /var/lib/loki if the directory is not owned by loki.

Pushes are rejected with entry too far behind or timestamp too old. Loki refuses samples older than a week by default. Lower max_age in loki.source.journal if the journal is older than that.

Alloy logs permission denied reading the journal. The group change only applies after the service restarts. Check with id alloy and restart Alloy.

Old logs are not deleted. Confirm retention_enabled: true and delete_request_store are set in the compactor block. Deletion happens after the compaction cycle plus retention_delete_delay, so expect a delay of a few hours.

Queries fail with maximum of series (500) reached. The query selects too many streams; narrow the stream selector with more labels.

Conclusion

You have a single-node Loki 3 instance on Ubuntu 24.04 that stores logs on local disk, deletes them after 30 days, and receives the system journal through Grafana Alloy, with LogQL available over the HTTP API.

From here you can add Grafana to browse and graph logs, parse application logs such as Nginx access logs into fields with Alloy's loki.process stages, and move chunk storage to an S3-compatible bucket when the volume outgrows a local disk.