all posts

The Best Ways to Host Grafana Loki in 2026

Ajay Kumar··13 min read

Loki is the only piece of the observability stack where the marketing line and the architecture line are actually the same sentence: it does not index your log content. Elasticsearch reads every line, tokenises it, and builds an inverted index that is frequently larger than the logs themselves. Loki reads a handful of labels, indexes those, compresses the rest into chunks, and drops them in a bucket. That single decision is why a Loki install can hold a terabyte of logs on object storage that costs less per month than a lunch — and it is also why the first Loki install at most companies falls over inside a fortnight.

I'm Ajay, I build PandaStack. We run Firecracker microVMs that regularly live for less time than a Loki batch window, so the log-shipping end of this is a daily problem for us rather than a thought experiment. We do not sell managed Loki, which means I have no particular stake in which of these options you pick. What I do have is a strong opinion about the order to make the decisions in, because 'where do I host Loki' is the third question and almost everyone asks it first.

This is a qualitative comparison, not a price sheet. Vendor pricing and free-tier limits change on their schedule, and Loki's own configuration schema has changed meaningfully between minor versions, so verify everything below against the docs for the exact version you intend to run.

The design in one paragraph, because everything else follows from it

A log line arrives at Loki with a set of labels and a timestamp. Loki groups lines by their exact label set — that group is a stream — and appends them to an in-memory chunk for that stream. When a chunk fills up or ages out, it is compressed and written to object storage: S3, GCS, Azure Blob, or anything speaking the S3 API. Separately, a small index records which chunks exist for which label sets over which time ranges. That index is tiny, because it describes streams, not words.

Querying works backwards through the same structure. A LogQL query starts with a stream selector — the curly-brace part — and Loki uses the index to find the chunks matching those labels in that time window. Then it downloads those chunks and brute-force greps them for whatever filter expression you wrote. That is genuinely the algorithm. It sounds primitive and it is extremely fast, on one condition: that the stream selector eliminates most of your data before the grep starts.

So Loki's economics are not really 'storage is cheap'. Storage being cheap is the easy half. The interesting half is that query cost is proportional to how many chunks your selector fails to eliminate, which means the shape of your labels determines both your ingest behaviour and your query bill. Get the labels right and Loki is the cheapest logging system available. Get them wrong and you have built a distributed system whose main activity is downloading its own data back out of a bucket.

The mental model that helps: Loki is not a search engine with cheap storage. It is a stream store with a very small index and a very fast grep. If your queries habitually start with a broad selector and lean on the filter to do the work, you are paying for the grep every time.

Cardinality: the thing that actually ruins Loki installs

Every unique combination of label values is a separate stream, and every stream carries an in-memory chunk on an ingester plus its own entries in the index. Add a label whose value is a request ID and you have not added a dimension to your data — you have instructed Loki to open a new stream, allocate a chunk, and flush a mostly-empty compressed object to S3 for every single request your service handles. The failure mode is not a slow query. It is ingesters ballooning in memory, chunks flushing at a fraction of their target size, an index that stops being small, and object storage filling with millions of tiny objects whose LIST and GET costs dwarf what you are paying to store them.

This is the same disease as Prometheus cardinality, which I wrote about at more length in the Prometheus hosting post, but the symptoms differ in an important way. Prometheus tends to die loudly, all at once, when a scrape blows up memory. Loki degrades: it gets slower, then it starts rejecting streams per tenant, then someone raises the per-user stream limit because the errors are annoying, and three weeks later you are running a query engine that has to touch fifty thousand objects to answer 'show me errors from the last hour'.

The rule I use is blunt and has never let me down: a label is allowed only if you would plausibly type it inside the curly braces of a query, and only if its set of possible values is small and bounded. Environment, cluster, region, service or app name, log level, maybe namespace. That is close to the whole list. Anything unbounded — request ID, trace ID, user ID, pod IP, a URL path containing an identifier, a container ID that changes on every deploy — belongs in the log line itself, or in structured metadata, and never in a label.

  • Good labels — bounded, low-cardinality, and things you actually select on: env, cluster, region, service, level, namespace. Roughly tens of values each, and the product of them stays in the thousands of streams, not the millions.
  • Bad labels — anything per-request or per-instance: request_id, trace_id, user_id, session_id, ip, path when it contains IDs, and container_id or pod name in a fleet that redeploys constantly.
  • Structured metadata — since Loki 3.x with the TSDB store and a recent schema version, you can attach high-cardinality key/value pairs to individual log lines without creating new streams. This is the correct home for trace_id, and it is what makes log-to-trace linking work without wrecking your index. Confirm it is available and enabled in your version before you design around it.
  • The log line itself — if it is JSON, LogQL's json parser can extract fields at query time and filter or aggregate on them. Slower than a label selector, but it costs nothing at ingest and never multiplies your stream count.

There is one more trap worth naming: out-of-order and per-stream rate limits. Loki historically required entries within a stream to arrive in timestamp order, and while modern versions accept out-of-order writes within a configurable window, a single stream still has a throughput ceiling. If you funnel your entire fleet into one stream because you were being careful about cardinality, you will hit that ceiling and start dropping lines. The answer is a handful of labels, not zero labels — enough to spread writes across streams, few enough that the count stays bounded.

Cardinality is not a performance tuning detail in Loki. It is the schema. You are designing a database every time you decide what goes in the curly braces.

The three deployment modes, and what each one costs you in ops

Loki ships as one binary that can play several roles, selected by a target flag. Understanding the three standard arrangements is most of the hosting decision, because 'where do I run Loki' really means 'which of these three am I signing up to operate'.

Monolithic (single binary)

One process running every component, backed by object storage. This is the mode people underestimate. A single Loki process on a modest VM, writing chunks to S3 or GCS, will comfortably handle the log volume of a small-to-medium company — we are talking tens to low hundreds of gigabytes a day depending on your query patterns and how much RAM you give it. It has no coordination, no ring to debug, no consistent-hash surprises. You can run more than one replica against shared object storage if you configure the ring, but the honest single-binary story is one process and a backup plan.

Ops burden: low. You own version upgrades, a config file, retention settings, and the knowledge that the process holds recent un-flushed chunks in memory. The write-ahead log mitigates that — configure it and give it a real disk — but a hard kill of an ingester without a WAL loses the logs it had not flushed yet, which is a genuinely bad property in the middle of the incident where you need those logs.

Simple scalable

The same binary split into read, write and backend targets, scaled independently, all sharing object storage. This is the mode Grafana's own Helm chart defaults to, and it is the right shape for most teams that have outgrown one box. The write path takes pushes and flushes chunks; the read path runs queriers and query frontends; the backend runs the compactor, ruler, index gateway and the other supporting pieces. You scale the read path when queries are slow and the write path when ingest is heavy, which is the whole point.

Ops burden: medium. You now have a ring, a shared cache to think about (memcached or similar, and it matters more than you expect), and three deployment surfaces instead of one. In exchange you get an architecture that grows without a rewrite.

Microservices

Every component — distributor, ingester, querier, query-frontend, query-scheduler, compactor, index-gateway, ruler — as its own deployment. Maximum control, maximum failure modes. This is what you run at genuinely large scale, and it is what Grafana runs to offer Loki as a service.

Ops burden: high, and it is a real job rather than a background one. My honest advice is that if you are reading a hosting comparison to decide, you are not in microservices territory yet. Start monolithic, move to simple scalable when a number forces you to, and treat microservices as something you graduate into with evidence rather than architect towards on day one.

What a minimal, correct single-binary config looks like

Below is the general shape of a monolithic Loki pointed at object storage with a retention policy. I want to be very clear about the caveat: Loki's config schema has changed between versions — the storage blocks, the schema versions, and where retention settings live have all moved at least once. Pin an exact Loki version, read that version's configuration reference, and treat this as a map of which knobs exist rather than a file to copy.

# loki-config.yaml -- monolithic Loki on object storage, 30 days retention.
# Pin your Loki version and diff this against that version's config reference:
# the storage and schema blocks have moved between releases more than once.

auth_enabled: false          # single tenant. Behind an auth proxy, always.

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  # Big log lines and slow queries both hit these. Raise deliberately, not reflexively.
  http_server_read_timeout: 60s
  http_server_write_timeout: 60s

common:
  instance_addr: 127.0.0.1
  path_prefix: /var/loki
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory       # one process. A real ring needs consul/etcd/memberlist.
  storage:
    s3:
      # Works against S3, GCS in interop mode, R2, MinIO -- anything S3-compatible.
      endpoint: s3.eu-west-1.amazonaws.com
      bucketnames: acme-loki-chunks
      region: eu-west-1
      s3forcepathstyle: false
      # Prefer instance/workload identity over static keys where you can.
      access_key_id: ${LOKI_S3_ACCESS_KEY_ID}
      secret_access_key: ${LOKI_S3_SECRET_ACCESS_KEY}

schema_config:
  configs:
    - from: 2026-01-01       # never edit a past entry -- append a new one instead
      store: tsdb
      object_store: s3
      schema: v13            # check which schema your version wants
      index:
        prefix: index_
        period: 24h

# The write-ahead log is what stops an ingester restart from eating the last
# few minutes of logs. Give it a real disk, not a tmpfs.
ingester:
  wal:
    enabled: true
    dir: /var/loki/wal
  chunk_idle_period: 30m
  chunk_target_size: 1572864
  max_chunk_age: 2h

limits_config:
  retention_period: 720h                 # 30 days
  ingestion_rate_mb: 10
  ingestion_burst_size_mb: 20
  # The guardrail that turns a cardinality bug into an error instead of an outage.
  max_streams_per_user: 10000
  max_label_names_per_series: 15
  reject_old_samples: true
  reject_old_samples_max_age: 168h

# Retention is not automatic. Without the compactor deleting expired chunks,
# retention_period only affects queries and your bucket grows forever.
compactor:
  working_directory: /var/loki/compactor
  retention_enabled: true
  retention_delete_delay: 2h
  delete_request_store: s3

# Optional but recommended: a per-stream retention override so noisy, low-value
# streams expire faster than the ones you actually investigate with.
# limits_config.retention_stream:
#   - selector: '{level="debug"}'
#     priority: 1
#     period: 72h
The single most common self-hosted Loki surprise: setting retention_period and assuming the data goes away. It does not. Retention only takes effect when the compactor is running with retention enabled and has permission to delete from the bucket. Plenty of teams have discovered a two-year-old bucket behind a thirty-day retention setting.

The hosting options

Grafana Cloud Logs (managed Loki)

The first-party managed option and the one to beat, particularly if you are already using Grafana Cloud for metrics and traces. You get Loki run by the people who write Loki, at a scale where they have already hit every failure mode you would discover slowly. It arrives pre-wired to Grafana, to Tempo for trace linking, and to their alerting. There is a free tier that a lot of small teams never outgrow, and paid usage is priced primarily on ingested volume and retention window rather than on the query layer.

I am not going to quote rates, because they change and because the units matter more than the headline number — model your actual daily ingest in gigabytes and your required retention against their current pricing page before you commit to anything. The genuine trade-offs: your logs leave your infrastructure, which is a compliance conversation in some industries; and the pricing model rewards discipline about what you ship, which is either a feature or an unpleasant surprise depending on how noisy your applications are today. Pick it when you want the whole Grafana stack from one vendor and would rather spend the engineering time elsewhere.

Self-hosted single binary on one VM with S3 or GCS

This is the option that deserves more respect than it gets. One VM, one Loki process, one config file, a bucket, and Grafana pointed at it. It handles more volume than most people assume, it costs the VM plus near-nothing for object storage, and there is no distributed system to reason about at three in the morning. If your daily ingest is measured in tens of gigabytes and your retention is measured in weeks, this is very likely the correct answer and you can stop reading the rest of this section.

What you are actually signing up for: version upgrades, a config file whose schema shifts between releases, WAL disk sizing, making sure the compactor can delete, and a bucket lifecycle policy as a backstop. Call it a couple of hours a quarter once it is running. The real risk is not cost or performance, it is that the box is a single point of failure for the system you use to debug outages — so put the chunks in object storage (not on the local disk), keep the config in git, and treat rebuilding the VM as a routine operation rather than a recovery.

This is the shape PandaStack fits, and you should read that as an interested party talking. An app on our platform is a full Ubuntu userspace inside a Firecracker microVM deployed from a git push, so installing the Loki binary in the build step and starting it with your config in the start step works exactly the way it would on any VM. The honest caveat is a structural one rather than a sales one: Loki's write path cannot scale to zero. Pushes arrive continuously, and an ingester that is asleep is an ingester dropping your logs. Scale-to-zero is a wonderful property for a Grafana instance nobody is looking at overnight; it is the wrong property for a log ingester. Run it always-on, and size it for the ingest rate rather than the query rate.

For a sense of scale rather than a quote: at our published rates of $0.054 per vCPU-hour and $0.0162 per GiB-hour, an always-on instance averaging a fifth of a vCPU with 4 GiB of RAM lands in the region of seventy-five cents a day, plus whatever your bucket costs. That is the real economics of self-hosted Loki — the compute is small and the storage is negligible, and what you are actually deciding is whether you want to own the config file.

The Helm chart on Kubernetes (simple scalable)

Grafana's official chart deploys the simple-scalable topology by default, with read, write and backend as separate workloads and a chunk cache alongside. If you are already running Kubernetes with a working GitOps pipeline, this is the well-trodden path: the chart is maintained, the defaults are sane, and the ecosystem around it — Alloy as a DaemonSet, kube-prometheus-stack for the metrics half — is the standard combination.

The same rule applies here as to every other 'run X on Kubernetes' answer: correct if the cluster already exists and is load-bearing, wrong if you would be adopting Kubernetes because of Loki. Two specific things to plan for. First, the cache is not optional at scale — an under-provisioned chunk cache turns every query into a fresh round of object-storage GETs, and it is the most common reason a Kubernetes Loki feels slower than someone's single VM. Second, give the write path proper pod disruption budgets and graceful termination: a rolling upgrade that hard-kills ingesters mid-flush is exactly the WAL scenario described above, except now it happens on a schedule.

The 'small team that just needs 30 days of logs' path

Here is the option a Loki comparison is structurally biased against: not running Loki. If you are a team of six with four services, what you need is the ability to answer 'what did that service print around 14:20 last Tuesday' within about a minute. Loki does that beautifully. So does a managed logging product's free tier. So, honestly, does structured JSON to stdout, shipped to a bucket with daily prefixes, plus a query tool that can read gzipped NDJSON from object storage. So does a logs table in Postgres with a retention job, if your volume is small and you already run Postgres.

The case for Loki even at small scale is real and worth stating fairly: LogQL is a good query language, the Grafana integration means logs and metrics live in one place with linked time ranges, and the operational unit is one binary rather than a cluster. But if choosing Loki means introducing your first piece of self-hosted stateful infrastructure, the correct question is whether the thirty days of logs are worth the on-call surface, and sometimes the answer is no. The cheapest logging system is the one nobody has to upgrade.

The adjacent options: VictoriaLogs, ClickHouse, OpenSearch

Worth naming, briefly and without a shootout I have not run. VictoriaLogs takes a similar low-index philosophy with a reputation for lower resource usage and a simpler single-binary story, and its query language is different enough to be a real migration rather than a swap. ClickHouse is what you reach for when logs and analytics are the same problem and you want SQL over columnar storage — more powerful, more schema work, and a different operational animal. OpenSearch or Elasticsearch is what you want if full-text search over log content is a genuine product requirement rather than a habit, and you should go in knowing the index will cost you what Loki's design specifically avoids.

If you are choosing Loki because of the Grafana integration and the label model, choose Loki. If you are choosing it because 'it is cheap', check whether the cheapness survives your actual query patterns first — that grep-the-chunks algorithm is only cheap when the selector does its job.

The agent side: Promtail is over, Alloy and OTel are what's next

For years the answer to 'how do logs get into Loki' was Promtail, and a lot of documentation on the internet still says so. Grafana has since consolidated its collectors into Grafana Alloy — their OpenTelemetry Collector distribution, which absorbs Promtail's file-tailing and relabelling pipelines alongside metrics and traces — and has put Promtail on a deprecation path with a published end-of-support date. Check the current status before you start a new install, but the direction is unambiguous: do not build a new pipeline on Promtail in 2026.

The two sensible choices now are Alloy, if you want Grafana's ecosystem and the pipeline stages that came from Promtail, or the upstream OpenTelemetry Collector, if you want one vendor-neutral agent for logs, metrics and traces and are willing to give up a few Loki-specific conveniences. Recent Loki versions accept OTLP directly on a native endpoint, which makes the plain OTel Collector path genuinely viable rather than a compromise — though how OTLP resource attributes get mapped onto Loki labels versus structured metadata is exactly the kind of behaviour that has changed between versions, so read your version's docs on that mapping specifically.

// config.alloy -- tail app logs, keep the label set boring, ship to Loki.
// The whole file is one long argument about what belongs in a label.

local.file_match "app" {
  path_targets = [{
    __path__ = "/var/log/app/*.log",
    // These become labels. Every one of them is bounded and small.
    service  = "checkout-api",
    env      = "prod",
    region   = "eu-west-1",
  }]
}

loki.source.file "app" {
  targets    = local.file_match.app.targets
  forward_to = [loki.process.app.receiver]
}

loki.process "app" {
  // Parse the JSON line so we can promote a couple of fields.
  stage.json {
    expressions = {
      level    = "level",
      trace_id = "trace_id",
      user_id  = "user_id",
      path     = "http.path",
    }
  }

  // GOOD: level has about five possible values. It is a label.
  stage.labels {
    values = { level = "" }
  }

  // GOOD: high-cardinality fields as structured metadata. Attached to the line,
  // queryable, and they do NOT create a new stream per value.
  stage.structured_metadata {
    values = {
      trace_id = "",
      user_id  = "",
    }
  }

  // BAD -- do not do this. Uncommenting it turns one stream into one stream per
  // trace, per user, per URL. Ingester memory climbs, chunks flush nearly empty,
  // the bucket fills with tiny objects, and queries slow down permanently.
  //
  // stage.labels {
  //   values = { trace_id = "", user_id = "", path = "" }
  // }

  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "https://loki.internal.example.com/loki/api/v1/push"
    basic_auth {
      username = "acme"
      password = sys.env("LOKI_PASSWORD")
    }
    // Batch settings are also your data-loss window on an ungraceful shutdown.
    batch_wait = "1s"
    batch_size = "1MiB"
  }

  // Applied to every stream from this agent. Same rule: bounded values only.
  external_labels = {
    cluster = "prod-eu",
    agent   = "alloy",
  }
}

The commented-out block is the entire post in eight lines. It is one stage, it looks harmless in review, and it is the difference between a Loki that costs nothing and a Loki that someone is going to spend a quarter fixing.

Shipping logs out of compute that dies before the batch flushes

Every agent configuration above assumes a machine that stays up. A DaemonSet tails files on a node that will still be there in an hour; a batch window of one second is a rounding error against a process lifetime measured in weeks. That assumption is doing enormous unacknowledged work, and it breaks completely on ephemeral compute — CI runners, serverless functions, scale-to-zero apps, and the microVM sandboxes we run, some of which exist for under a second in total.

The failure is specific and worth naming precisely: the VM writes its logs to a filesystem that is a copy-on-write clone, the agent buffers them for its batch interval, and then the machine is destroyed and the clone is unlinked. The logs were real, they were correct, and they never left. You do not get an error — you get an empty query result forty minutes later when someone opens a ticket. I wrote a whole piece on this problem for Firecracker specifically; the short version for Loki is three rules.

  1. Push, never pull, and push from outside the guest where you can. Anything that requires scraping or tailing a machine that may already be gone is the wrong shape. Where the platform can capture output on the host side — a serial console, a log file on the host, a vsock stream to a host collector — that survives the guest's death by construction.
  2. Make the batch window shorter than the machine's expected lifetime, and flush on shutdown. A one-second batch is fine for a long-lived node and catastrophic for a two-hundred-millisecond sandbox. If the agent runs inside the ephemeral machine, it needs an explicit flush in the termination path, and the termination path needs to actually run — a hard kill gives you no such opportunity, which is a reason to prefer host-side capture over in-guest agents for anything short-lived.
  3. Do not put the ephemeral identity in a label. This is where the cardinality rule and the ephemerality problem collide hardest. sandbox_id, run_id, invocation_id, container_id: every one of them is unbounded by definition, because a new one is minted per unit of work. They belong in structured metadata or in the JSON line. The label set for a fleet of a million short-lived machines should look exactly like the label set for three long-lived ones.

The pattern that works for genuinely short-lived compute is a host-side collector: the ephemeral machine writes to stdout or a known file, the platform captures that stream on the host as it is produced, and a single always-running agent on the host batches and pushes to Loki with sane labels. The ephemeral thing then does not need an agent, a config, network egress to your Loki, or credentials — which also closes a security hole, because a guest running untrusted code that can push directly to your logging endpoint is a guest that can forge or flood your logs.

#!/usr/bin/env bash
# Last-resort flush for a short-lived job: push directly to Loki's API in a trap.
# Use this when there is genuinely no host-side capture. It is a fallback, not
# an architecture -- the label set is fixed and boring, the job identity is not
# a label, and the whole thing is best-effort by design.
set -uo pipefail

LOG=/tmp/job.log
LOKI_URL="${LOKI_URL:?}/loki/api/v1/push"

flush() {
  [ -s "$LOG" ] || return 0
  ts=$(date +%s%N)
  # Job identity travels in the LINE, not in the stream labels.
  payload=$(jq -Rn --arg ts "$ts" --arg run "${RUN_ID:-unknown}" \
    --rawfile body "$LOG" '{
      streams: [{
        stream: { service: "batch-job", env: "prod", level: "info" },
        values: [[$ts, ("run_id=" + $run + " " + $body)]]
      }]
    }')
  curl -sS --max-time 5 -X POST "$LOKI_URL" \
    -H "Content-Type: application/json" \
    -u "${LOKI_USER}:${LOKI_PASSWORD}" \
    --data-binary "$payload" >/dev/null || true
}

# Flush on normal exit AND on the signals you are likely to receive.
trap flush EXIT INT TERM

run-the-actual-job 2>&1 | tee -a "$LOG"
A trap only helps you if the process gets a signal. TTL expiry, an OOM kill, or a host under pressure can all take the machine without warning, and no amount of in-guest cleverness recovers those bytes. Treat in-guest flushing as the fallback and host-side capture as the design.

The options side by side

  • Grafana Cloud Logs — Model: fully managed Loki, priced mainly on ingested volume and retention, wired into Grafana and Tempo out of the box. Ops burden: none. Best for: teams who want the whole Grafana stack from one vendor and can send logs off-premises. Watch: model your real daily ingest before committing, and verify current rates on their pricing page.
  • Self-hosted single binary on a VM with S3/GCS — Model: one process, one config file, chunks in a bucket. Ops burden: low; version bumps, WAL disk, and making sure the compactor can delete. Best for: tens of gigabytes a day and weeks of retention, which is most companies. Watch: it is a single point of failure for the tool you debug outages with, so keep config in git and rebuilding routine.
  • Kubernetes with the official Helm chart (simple scalable) — Model: read/write/backend split with a chunk cache, GitOps-managed alongside the rest of the cluster. Ops burden: medium, and it inherits whatever the cluster already costs you. Best for: teams already running Kubernetes at a scale where one box is genuinely not enough. Watch: cache sizing and graceful ingester shutdown during rolling upgrades.
  • Microservices Loki — Model: every component independently deployed and scaled. Ops burden: high, and it is somebody's actual job. Best for: very large scale with evidence that simple scalable has stopped working. Watch: do not architect towards this preemptively.
  • No Loki at all — Model: a managed logging product, structured JSON to object storage, or a logs table in a Postgres you already run. Ops burden: zero to trivial. Best for: small teams whose real requirement is thirty days of searchable output, not a query language. Watch: you give up LogQL and the linked-time-range Grafana experience, which are genuinely nice.

How to choose in ten minutes

  1. Write down three numbers: gigabytes of logs per day, required retention in days, and how many distinct label values you will actually query on. The third number is the one that decides whether Loki works for you at all.
  2. Decide the label set before the host. Write the curly-brace part of your five most common queries. If a request ID or a trace ID appears in one of them, redesign now rather than after ingesters start OOMing.
  3. If daily ingest is in the tens of gigabytes and retention is in weeks, default to a single binary on one VM with object storage. Prove you need more before you build more.
  4. Pick the agent second, and pick Alloy or the OpenTelemetry Collector — not Promtail, which is on its way out. If you already run an OTel Collector for traces, using it for logs too is one fewer agent to operate.
  5. Ask separately how logs escape anything ephemeral. CI runners, functions and sandboxes need push-with-flush or host-side capture, and their identity belongs in the line, not the labels.
  6. Whichever host you pick, verify the retention path end to end. Set the period, run the compactor with deletes enabled, and check the bucket actually shrinks after the window elapses.

The short version

Grafana Cloud Logs if you want managed and your logs can leave your infrastructure. A single Loki binary on one VM with S3 or GCS if you have tens of gigabytes a day and want to own the config file — this is the underrated answer and it is correct more often than the internet implies. The Helm chart's simple-scalable mode if Kubernetes already exists and one box has genuinely stopped being enough. Microservices only with evidence. And seriously consider not running Loki if what you need is thirty days of searchable output and this would be your first self-hosted stateful service.

But the hosting choice is the least consequential decision here, which is the uncomfortable point of the whole post. Every one of these options works if your labels are bounded, and every one of them degrades the same miserable way if they are not. Design the label set first, ship with Alloy or OTel rather than Promtail, verify that retention actually deletes, and make sure your ephemeral machines flush before they die. Do those four things and Loki is the cheapest good decision in your observability stack. Skip them and the host you picked will not save you.

Frequently asked questions

Why does Grafana Loki get slow or expensive over time?

Almost always label cardinality. Every unique combination of label values is a separate stream with its own in-memory chunk on an ingester and its own index entries, so adding a high-cardinality label like request_id, trace_id or user_id creates a new stream per request. Chunks then flush nearly empty, object storage fills with millions of tiny objects whose GET and LIST costs dwarf the storage, and queries slow permanently. Keep labels bounded — env, cluster, region, service, level — and put high-cardinality values in structured metadata or in the log line itself.

Which Loki deployment mode should I use?

Start monolithic. A single Loki binary on one VM writing chunks to S3 or GCS handles tens to low hundreds of gigabytes a day and has no ring, no coordination and one config file to own. Move to simple scalable — the read, write and backend split that Grafana's Helm chart deploys by default — when a specific number forces you to, typically query latency or ingest throughput. Microservices, where every component is its own deployment, is a real operational job and should be something you graduate into with evidence rather than architect towards on day one.

Is Promtail deprecated, and what should I use instead?

Yes. Grafana has consolidated its collectors into Grafana Alloy, its OpenTelemetry Collector distribution, which absorbs Promtail's file-tailing and pipeline stages alongside metrics and traces, and Promtail is on a published deprecation path. Do not start a new pipeline on Promtail. Use Alloy if you want Grafana's ecosystem and the familiar pipeline stages, or the upstream OpenTelemetry Collector if you prefer one vendor-neutral agent — recent Loki versions accept OTLP on a native endpoint, though the mapping from OTLP resource attributes to labels versus structured metadata varies by version, so check your version's docs.

How do I get logs into Loki from a container or VM that only lives a few seconds?

Do not rely on an in-guest agent's batch window, because a one-second batch is catastrophic when the machine lives 200 milliseconds. Prefer host-side capture: the ephemeral machine writes to stdout or a known file, the platform captures that stream on the host as it is produced, and a single always-running agent pushes to Loki with a fixed low-cardinality label set. If you must ship from inside, shorten the batch window and flush explicitly in a shutdown trap, accepting that an OOM or a hard kill gives you no opportunity to run it. Critically, the ephemeral identity — sandbox_id, run_id, invocation_id — goes in the log line, never in a label.

Does setting retention_period in Loki actually delete my logs?

Not on its own. retention_period affects what queries return, but the data is only removed from object storage when the compactor is running with retention enabled and has delete permissions on the bucket, with delete_request_store configured. Many teams discover a bucket holding two years of chunks behind a thirty-day retention setting. Configure the compactor explicitly, add an object-storage lifecycle policy as a backstop, and verify the bucket actually shrinks after the retention window elapses rather than assuming it does.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.