Promtail lost support on 2 March 2026, and if it still ships your logs to Loki, every week that passes it does so without patches. Grafana Alloy is its replacement and ships alloy convert, which translates a Promtail configuration in one step. On 28 September 2026 I used it to migrate a Promtail 3.6.11 with files, Docker containers and real pipelines to Alloy 1.20.0, on top of Loki 3.7.8. This guide covers what converts cleanly, what changes without warning and how to cut over without losing or duplicating a single line. There is also a Spanish version of this guide.

Key takeaways

  • Grafana has declared Promtail end of life since 2 March 2026, and the last image published on Docker Hub is 3.6.11, from 13 May 2026.
  • alloy convert --source-format=promtail translated my configuration with no errors and added legacy_positions_file on its own, so Alloy resumes files where Promtail left them.
  • The conversion silently changed multiline: Promtail cuts blocks at 128 lines and the max_lines = 0 the converter writes never cuts them (7 entries against 4 in my test).
  • loki.source.docker does not inherit positions: on startup, Alloy reread the whole history of every container Promtail had already shipped.
  • With a temporary stage.drop using older_than = "1m" and a per-line sequence check, the cutover ended with 1,295 and 1,298 lines in Loki, 0 duplicated and 0 missing.

Why you have to migrate off Promtail now

The deadline has passed. The Promtail page in the Loki documentation[1] opens with this warning: "Promtail is end of life (EOL) as of March 2, 2026. Commercial support has ended. No future support or updates will be provided." The same page adds that if you use Promtail, you must migrate to Alloy or another supported client. The exception is lambda-promtail, the AWS Lambda client, which is not part of the end of life.

On Docker Hub, the grafana/promtail[2] repository’s last tag is 3.6.11, published on 13 May 2026 for amd64, arm64 and arm. Loki, on the other hand, keeps releasing: 3.7.8 came out on 17 September. Every new Loki patch widens the distance to an agent that is no longer tested against it.

The replacement is Grafana Alloy[3], a collector that gathers metrics, traces and profiles as well as logs. Its configuration is not YAML but its own language of components wired to each other. The official guide to migrating from Promtail[4] offers two paths: convert the configuration with alloy convert, or start Alloy on the Promtail YAML with --config.format=promtail as a transition step.

The Promtail configuration I migrated

I built the lab with Docker 29.5.2 on an aarch64 machine: Loki 3.7.8, Grafana 13.2.2, Promtail 3.6.11 and Alloy v1.20.0, all from Docker Hub. Two generators write a line with a sequence number (seq) every 0.2 s. One writes to a file, /logs/app.log, and the other, a container named b6p7-web, writes JSON to its standard output.

One line in ten has level=debug and the pipeline drops it on purpose. If you are starting from scratch, the guide to installing Loki with Docker sets up the base.

The first half of promtail.yaml sets the positions file, the destination and a job that reads files through four stages:

positions:
  filename: /positions/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push
    external_labels:
      host: lab01

scrape_configs:
  - job_name: app-files
    static_configs:
      - targets: [localhost]
        labels:
          job: app-files
          __path__: /logs/*.log
    pipeline_stages:
      - regex:
          expression: '^(?P<ts>\S+) level=(?P<level>\w+) seq=(?P<seq>\d+)'
      - timestamp:
          source: ts
          format: RFC3339
      - labels:
          level:
      - drop:
          source: level
          value: debug

The regex stage extracts the timestamp and the level, timestamp uses the line’s own time instead of the read time, labels turns the level into a Loki label and drop throws away the debug lines.

The second half discovers containers through the Docker API, filtered by a label so it does not pick up other projects’ containers, and applies a json stage:

  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
        filters:
          - name: label
            values: ["logging=b6p7-lab"]
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: container
      - source_labels: ['__meta_docker_container_log_stream']
        target_label: stream
      - target_label: job
        replacement: docker
    pipeline_stages:
      - json:
          expressions:
            level: level
      - labels:
          level:
      - drop:
          source: level
          value: debug

The relabel_configs strip the leading slash from the container name, record whether the line came from stdout or stderr and set job="docker". It is the usual shape of a Promtail that collects a Docker server’s logs.

What alloy convert generates from your configuration

alloy convert translated this configuration without a single error. I ran it from the same Alloy image, mounting the working directory:

docker run --rm -u $(id -u) -v $PWD:/w grafana/alloy:v1.20.0 \
  convert --source-format=promtail \
  --report=/w/alloy/report.txt \
  --output=/w/alloy/config.alloy /w/promtail/promtail.yaml

The command exited with code 0 and wrote nothing to stderr. The --report file held only two generic warnings, which appear with any configuration:

(Warning) If you have a tracing set up for Promtail, it cannot be
migrated to Alloy automatically. Refer to the documentation on how
to configure tracing in Alloy.
(Warning) Alloy's metrics are different from the metrics emitted by
Promtail. If you rely on Promtail's metrics, you must change your
configuration, for example, your alerts and dashboards.

The second warning matters if you alert on metrics such as promtail_sent_entries_total. In Alloy the equivalents are named loki_write_sent_entries_total, loki_source_file_read_lines_total and so on.

The file job became two components, loki.process with the four stages and loki.source.file. This is the second one:

loki.source.file "app_files" {
  targets = [{
    __address__ = "localhost",
    __path__    = "/logs/*.log",
    job         = "app-files",
  }]
  forward_to = [loki.process.app_files.receiver]

  file_match {
    enabled = true
  }
  legacy_positions_file = "/positions/positions.yaml"
}

The key line is the last one. The converter pointed legacy_positions_file at Promtail’s positions file without being asked. According to the loki.source.file reference[5], Alloy converts that file to its own format only if it does not have one yet.

The Docker job became discovery.docker, discovery.relabel, loki.process and loki.source.docker, with the same rules. The featured image is the Graph tab of the Alloy UI with that pipeline loaded.

What alloy convert does not convert cleanly

To find the limits, I fed the converter a second YAML with less common options: limits_config, tracing, server.log_level, labels with environment variables and a multiline stage. It exited with code 0 and four warnings. These two are new, copied verbatim:

(Warning) limits_config rate limiting settings (readline_rate,
readline_burst, readline_rate_drop) have been converted to stage.limit
stages in each loki.process component. Unlike Promtail's global rate
limiter, these limits apply independently per pipeline rather than
across all pipelines combined.
(Warning) The converter does not support converting the provided
server.log_level config: The equivalent feature in Alloy is to use the
logging config block to set the level argument.

The first one changes behaviour: a limit of 500 lines per second in Promtail becomes 500 per pipeline, so with three jobs the real ceiling triples. The dangerous part, though, was what changed with no warning at all:

Promtail option What alloy convert wrote What to do
multiline without max_lines max_lines = 0 Write max_lines = 128 or delete the line
host: ${HOSTNAME} The literal string "${HOSTNAME}" Use sys.env("HOSTNAME")
positions.sync_period Nothing Alloy has no such setting in loki.source.file
positions.ignore_invalid_yaml Nothing Check on_positions_file_error
max_line_size_truncate stage.truncate with 256KiB Nothing, the conversion is correct

The variables have a reason behind them: Promtail only expands them with -config.expand-env=true, which is a command-line flag. I tried to pass it to the converter with --extra-args and it answered "Error: (Critical) extra arguments are not supported for the promtail converter: [-config.expand-env true]".

The silent multiline change

In Promtail, a multiline block is cut at 128 lines unless you say otherwise. The converter writes max_lines = 0, and in the Alloy 1.20 multiline stage code[6] a block is closed when the line counter equals max_lines. Since the counter starts at 1, with 0 it is never closed by size. I checked it with a file holding three 200-line traces and one single line:

Agent and setting Entries in the output
Promtail 3.6.11, no max_lines 7 (each trace split into 128 + 72)
Alloy 1.20.0, converted max_lines = 0 4
Alloy 1.20.0, no max_lines 7

If your Java or Python traces run past 128 lines, after the migration they reach Loki as one much larger entry. Check Loki’s line size limit, max_line_size, before the cutover.

How to run Promtail and Alloy side by side without mixing data

Before switching anything off, run Alloy next to Promtail with an extra label that keeps their streams apart. I added agent = "alloy-shadow" to external_labels in a copy of the converted configuration and gave that Alloy its own data volume, with no access to Promtail’s positions file. Its logs say so plainly: "will not convert the legacy positions file as it is not valid or does not exist".

Looking at the graph is not enough to compare them. I wrote verify.py, a 39-line script that asks the Loki API for every line of a selector. It extracts seq and counts duplicates and gaps, ignoring multiples of 10 (the dropped debug lines). In LogQL, {agent=""} selects the streams without that label, which are Promtail’s:

python3 verify.py '{job="docker", agent=""}'
python3 verify.py '{job="docker", agent="alloy-shadow"}'

Both streams had the same label set apart from agent: container, host, job, level, service_name and stream, with 0 duplicates and 0 gaps each. No agent sets service_name: Loki 3 adds it on ingestion. One telling detail: the side-by-side Alloy started at seq=1 in both jobs, because without positions it reads each file from the start and each container from its first line.

How to keep positions so logs are not shipped twice

Files inherit their position and containers do not, and that is the main trap of the cutover. Promtail keeps both in the same YAML. This is how it looked when I stopped it:

positions:
  /logs/app.log: "50578"
  cursor-592b72106356...: "1790572232"

The first entry is a byte offset. The second is the container’s position, and it is not an offset but a Unix time in whole seconds. In the first cutover I mounted that volume read-only at the same path, /positions, and Alloy logged "successfully converted legacy positions file to the new format". The file resumed exactly at byte 50578.

Docker was different. The loki.source.docker reference[7] explains that the component stores its positions in its own data directory, and it has no option to read Promtail’s or to start at the end. The loki_source_docker_target_entries_total metric showed 1,045 lines read right after startup: the container’s whole history, which Promtail had already shipped.

In that first cutover, Loki did not store the copies: verify.py found 0 duplicates. The Loki head block[8] drops an entry with the same timestamp and content as another one in the same stream, and the query iterator removes repeats as well. That is a safety net, not a plan.

Loki’s own code warns that duplicates can slip in if the stream was removed for inactivity, and it only takes Alloy producing one different label for the copy to land in another stream.

The fix I used is a temporary stage at the top of the Docker loki.process that drops anything already old:

loki.process "docker" {
  forward_to = [loki.write.default.receiver]

  stage.drop {
    older_than          = "1m"
    drop_counter_reason = "promtail_cutover"
  }

older_than drops lines whose timestamp is more than one minute old. Pick a value longer than the cutover will take, or you will lose the lines written while no agent was reading. Remove the stage once Alloy has saved its positions: if you leave it, an Alloy restart after a long outage would throw away legitimate logs.

How to cut over and prove no line is lost or duplicated

The cutover takes five steps, and I ran them with a script, cutover.sh, that builds the lab from scratch:

  1. Start Loki, the generators and Promtail, and let Promtail ship for 180 s.
  2. Stop Promtail with docker compose stop, which saves its positions on exit.
  3. Wait 10 s with no agent, to simulate a real cutover window.
  4. Start Alloy with final.alloy and Promtail’s positions volume mounted at /positions.
  5. Count with LogQL and with verify.py.

The Alloy service in compose.yaml is short. What matters is the volumes:

  alloy:
    image: grafana/alloy:v1.20.0
    command: ["run", "--server.http.listen-addr=0.0.0.0:12345",
              "--storage.path=/var/lib/alloy/data",
              "/etc/alloy/config.alloy"]
    volumes:
      - ./alloy/final.alloy:/etc/alloy/config.alloy:ro
      - app-logs:/logs:ro
      - promtail-positions:/positions:ro
      - alloy-data:/var/lib/alloy/data
      - /var/run/docker.sock:/var/run/docker.sock:ro

alloy-data holds the new positions: lose it and Alloy reads everything again. The Docker socket is mounted read-only, but it still grants full access to the Docker API, so treat it as an administrator permission.

With the generators stopped, this LogQL query returns how many lines Loki holds for each job:

sum(count_over_time({job="docker"}[1h]))

The expected number comes without Loki: the highest sequence minus the debug lines. These were the results of the cutover on 28 September 2026:

Job Highest sequence Expected In Loki Duplicated Missing
app-files 1,438 1,295 1,295 0 0
docker 1,442 1,298 1,298 0 0

Alloy read 1,442 lines from the container, its whole history from seq=1, and the temporary stage dropped 773 of them with the reason promtail_cutover. The ones from the last minute before the cutover were sent again, and the table shows Loki did not store them twice. In Grafana, the same per-minute count stays flat through the switch from one agent to the other:

Grafana 13.2 Explore with sum by (job) of count_over_time over the two lab streams: the line stays flat through the switch from Promtail to Alloy around 05:16.

If you already run Grafana with Docker, repeat that query with your own labels before and after the cutover. In production you will not have a sequence number, but an application that logs at a steady rate leaves an equally clear graph.

What alternatives you have if you do not want to move to Alloy

Alloy is the path with the least work if you come from Promtail, because it is the only one with a converter. The Loki documentation accepts other clients: the Docker logging driver for Loki, Fluent Bit, Vector or the OpenTelemetry Collector sending over OTLP. None of them reads your promtail.yaml, so with any of them you rewrite the pipelines by hand.

For a quick live view of the logs without running any agent, Dozzle reads the Docker socket, but it keeps no history and does not replace Loki. If you want to see how Alloy fits with Tempo and Mimir, the article on the Grafana stack for open observability covers it. Alloy’s metrics can also go to a Prometheus in Docker.

Frequently asked questions

Can I keep using Promtail after 2 March 2026?

It runs, but without patches or support. Grafana says there will be no more updates, and the last image on Docker Hub is 3.6.11 from May 2026. Every new vulnerability in its dependencies will stay unfixed.

Can I start Alloy on my promtail.yaml without converting it?

Yes, with alloy run --config.format=promtail. I tried it with the same configuration: it started and began reading /logs/app.log, and Docker discovery failed only because I did not mount the socket in that test. The official guide presents it as a transition step and advises against the flag that skips conversion errors, --config.bypass-conversion-errors, in production.

Does Alloy use the same positions file as Promtail?

No. It keeps its own inside --storage.path, one per component. For files it can import Promtail’s with legacy_positions_file; for Docker containers there is no import and it starts from the beginning of the log.

Conclusion

alloy convert does the heavy lifting well: it translated my two jobs, their relabels and their pipelines with no errors, and wired Promtail’s positions file on its own. What it does not tell you is what hurts most in production: multiline loses its 128-line limit, environment variables stay as literal text and Docker containers are reread in full.

My recommendation: convert, check the output against the table in this article, run Alloy side by side with its own label and cut over with a temporary older_than stage. Then count: if the highest sequence minus the dropped lines does not match what is in Loki, something was lost or duplicated.

Sources: [1] Loki documentation: Promtail page and end-of-life notice[1], [2] Alloy guide to migrating from Promtail[4], [3] Alloy convert command reference[9], [4] Grafana Alloy 1.20.0 release notes[10], [5] loki.source.file reference[5], [6] loki.source.docker reference[7], [7] loki.process reference[11], [8] Alloy 1.20.0 multiline stage code[6], [9] Promtail 3.6.11 multiline stage code[12], [10] Loki 3.7.8 deduplication code[8], [11] grafana/promtail image tags on Docker Hub[2].

Sources

  1. Promtail page in the Loki documentation
  2. grafana/promtail
  3. Grafana Alloy
  4. official guide to migrating from Promtail
  5. loki.source.file reference
  6. Alloy 1.20 multiline stage code
  7. loki.source.docker reference
  8. Loki head block
  9. Alloy convert command reference
  10. Grafana Alloy 1.20.0 release notes
  11. loki.process reference
  12. Promtail 3.6.11 multiline stage code