<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: opscanopy</title>
    <description>The latest articles on DEV Community by opscanopy (@opscanopy).</description>
    <link>https://dev.to/opscanopy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3982315%2F76cf6c25-3b03-4a82-9a14-c0f39bab7ea1.png</url>
      <title>DEV Community: opscanopy</title>
      <link>https://dev.to/opscanopy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/opscanopy"/>
    <language>en</language>
    <item>
      <title>Why Did Prometheus Drop My Target? Debugging relabel_configs</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:28:51 +0000</pubDate>
      <link>https://dev.to/opscanopy/why-did-prometheus-drop-my-target-debugging-relabelconfigs-56fk</link>
      <guid>https://dev.to/opscanopy/why-did-prometheus-drop-my-target-debugging-relabelconfigs-56fk</guid>
      <description>&lt;p&gt;You added a new exporter, reloaded Prometheus, opened &lt;code&gt;/targets&lt;/code&gt;, and it isn't there. No error in the logs. The scrape config parsed fine. The exporter is up and you can &lt;code&gt;curl&lt;/code&gt; its &lt;code&gt;/metrics&lt;/code&gt; by hand. But Prometheus dropped your target and won't tell you why. Or worse — the target shows up, but a label you depend on for routing or dashboards has silently disappeared. Both symptoms almost always trace back to one place: &lt;code&gt;relabel_configs&lt;/code&gt;. This post walks through how to debug &lt;code&gt;relabel_configs&lt;/code&gt;, where it differs from &lt;code&gt;metric_relabel_configs&lt;/code&gt;, and the handful of mistakes that account for nearly every dropped target.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: a missing target in /targets, or a label that vanished
&lt;/h2&gt;

&lt;p&gt;There are two distinct failures and it helps to name them before you start digging.&lt;/p&gt;

&lt;p&gt;The first is the &lt;strong&gt;dropped target&lt;/strong&gt;: it never appears under &lt;code&gt;/targets&lt;/code&gt; at all, not even in a "down" state. Service discovery found it, but a &lt;code&gt;keep&lt;/code&gt; or &lt;code&gt;drop&lt;/code&gt; rule removed it before the scrape ran. Prometheus does not log this — from its point of view, nothing went wrong.&lt;/p&gt;

&lt;p&gt;The second is the &lt;strong&gt;disappearing label&lt;/strong&gt;: the target scrapes fine, but a label you expected is gone, or got overwritten with something unexpected. You see this in &lt;code&gt;/targets&lt;/code&gt; (hover the labels) or by querying the series and noticing the dimension you wanted to group by isn't there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The target you expect is simply absent from the list:&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:9090/api/v1/targets | jq &lt;span class="s1"&gt;'.data.activeTargets[].labels.job'&lt;/span&gt;
&lt;span class="c"&gt;# "node-exporter"&lt;/span&gt;
&lt;span class="c"&gt;# "blackbox"&lt;/span&gt;
&lt;span class="c"&gt;#   ← your "api" job never shows up&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a target is silently absent, the cause is upstream of the scrape. That's relabeling. The good news: relabeling is deterministic. Given the same input labels and the same rules, you get the same outcome every time, which means you can reproduce it offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  relabel_configs vs metric_relabel_configs: where each one runs
&lt;/h2&gt;

&lt;p&gt;The two config blocks apply the &lt;em&gt;exact same&lt;/em&gt; relabeling actions and semantics. The only difference is &lt;strong&gt;where&lt;/strong&gt; in the scrape lifecycle they run — and that difference decides which symptom you're debugging.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;relabel_configs&lt;/code&gt; runs &lt;strong&gt;at scrape time, before the scrape&lt;/strong&gt;, on the target labels coming from service discovery. These are the labels that decide &lt;em&gt;whether a target gets scraped at all&lt;/em&gt; and what its identity (&lt;code&gt;job&lt;/code&gt;, &lt;code&gt;instance&lt;/code&gt;, &lt;code&gt;__address__&lt;/code&gt;) is. A &lt;code&gt;keep&lt;/code&gt;/&lt;code&gt;drop&lt;/code&gt; here removes a whole target. This is the block to inspect when a target is missing from &lt;code&gt;/targets&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;metric_relabel_configs&lt;/code&gt; runs &lt;strong&gt;after the scrape&lt;/strong&gt;, on every sample's label set as it's ingested. A &lt;code&gt;keep&lt;/code&gt;/&lt;code&gt;drop&lt;/code&gt; here removes individual time series, not the target. This is the block to inspect when the target is present but specific series or labels are missing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fdebug-prometheus-relabeling-diagram.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fdebug-prometheus-relabeling-diagram.svg" alt="The Prometheus scrape lifecycle showing service discovery and __meta_ labels, then relabel_configs which can drop a whole target, then the scrape, then metric_relabel_configs which can drop individual samples, then the TSDB." width="1200" height="620"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes_sd_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod&lt;/span&gt;

    &lt;span class="c1"&gt;# Runs BEFORE the scrape, on discovery labels (__meta_*, __address__).&lt;/span&gt;
    &lt;span class="c1"&gt;# A keep/drop here removes the whole target.&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_kubernetes_pod_annotation_prometheus_io_scrape&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;

    &lt;span class="c1"&gt;# Runs AFTER the scrape, on each sample. A drop here removes series,&lt;/span&gt;
    &lt;span class="c1"&gt;# not the target.&lt;/span&gt;
    &lt;span class="na"&gt;metric_relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__name__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drop&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;go_gc_.*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your target is missing, you never reach &lt;code&gt;metric_relabel_configs&lt;/code&gt; — debug &lt;code&gt;relabel_configs&lt;/code&gt; first. If the target is present but a series is gone, it's the other block. Getting this distinction right is half the battle when you're searching "metric_relabel_configs vs relabel_configs" at 2 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual suspects
&lt;/h2&gt;

&lt;p&gt;Almost every dropped target comes from one of these. Each is easy to make and invisible until you reproduce it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A keep regex that does not match (because the regex is anchored)
&lt;/h3&gt;

&lt;p&gt;This is the number-one cause. &lt;strong&gt;Prometheus anchors every relabel regex&lt;/strong&gt; — internally it wraps your pattern as &lt;code&gt;^(?:&amp;lt;your regex&amp;gt;)$&lt;/code&gt;. The pattern must match the &lt;em&gt;entire&lt;/em&gt; joined source value, not a substring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
  &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;          &lt;span class="c1"&gt;# anchored to ^(?:api)$&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps a target whose &lt;code&gt;job&lt;/code&gt; is exactly &lt;code&gt;api&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; keep &lt;code&gt;api-server&lt;/code&gt;, &lt;code&gt;api-prod&lt;/code&gt;, or &lt;code&gt;payments-api&lt;/code&gt;. With a &lt;code&gt;keep&lt;/code&gt; action, anything that fails to match is dropped — so your &lt;code&gt;api-server&lt;/code&gt; target silently vanishes. The fix is to match what you actually mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
  &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.*&lt;/span&gt;        &lt;span class="c1"&gt;# ^(?:api.*)$ — matches api, api-server, api-prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A drop that is too broad
&lt;/h3&gt;

&lt;p&gt;The mirror image. An unanchored mental model plus a greedy regex catches more than intended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__name__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drop&lt;/span&gt;
  &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.*_bucket&lt;/span&gt;   &lt;span class="c1"&gt;# drops EVERY *_bucket series, including ones you need&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keep&lt;/code&gt; is an allow-list gate; &lt;code&gt;drop&lt;/code&gt; is a deny-list gate. A too-broad &lt;code&gt;drop&lt;/code&gt; in &lt;code&gt;metric_relabel_configs&lt;/code&gt; quietly deletes series you wanted to keep, and you only notice when a dashboard goes blank.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrong source_labels, or the wrong join
&lt;/h3&gt;

&lt;p&gt;When a rule lists multiple &lt;code&gt;source_labels&lt;/code&gt;, Prometheus joins their values with the &lt;strong&gt;separator&lt;/strong&gt; — which defaults to a single semicolon &lt;code&gt;;&lt;/code&gt; — &lt;em&gt;before&lt;/em&gt; matching the regex. If you forget the separator, your regex never matches the joined string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# job="api", instance="10.0.0.1:9090" joins to "api;10.0.0.1:9090"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
  &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;          &lt;span class="c1"&gt;# ✗ never matches "api;10.0.0.1:9090"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need a regex that accounts for the &lt;code&gt;;&lt;/code&gt;, e.g. &lt;code&gt;api;.*&lt;/code&gt;. A missing source label isn't an error either — Prometheus treats an absent label as the empty string when joining, so &lt;code&gt;source_labels: [does_not_exist]&lt;/code&gt; joins to &lt;code&gt;""&lt;/code&gt; and a &lt;code&gt;keep: regex: ".+"&lt;/code&gt; drops everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  A replacement that overwrote &lt;strong&gt;address&lt;/strong&gt; (or deleted a label)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;replace&lt;/code&gt; has a subtle, real behavior: &lt;strong&gt;if the regex doesn't match, the label is left unchanged; but if it matches and the expanded replacement is the empty string, the target label is deleted, not set to blank.&lt;/strong&gt; Overwrite &lt;code&gt;__address__&lt;/code&gt; with an empty value and the target effectively loses its scrape address.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# If prometheus_io_port is absent, the joined value won't match this regex,&lt;/span&gt;
&lt;span class="c1"&gt;# so __address__ is left alone. But a regex that DOES match and expands to ""&lt;/span&gt;
&lt;span class="c1"&gt;# would DELETE __address__ entirely.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__address__&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;__meta_kubernetes_pod_annotation_prometheus_io_port&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;replace&lt;/span&gt;
  &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;([^:]+)(?::\d+)?;(\d+)&lt;/span&gt;
  &lt;span class="na"&gt;replacement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$1:$2&lt;/span&gt;
  &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__address__&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most insidious one, because an empty &lt;code&gt;instance&lt;/code&gt; or &lt;code&gt;__address__&lt;/code&gt; doesn't throw — it just produces a target that can't be scraped or that collides with another.&lt;/p&gt;

&lt;h2&gt;
  
  
  A debugging workflow
&lt;/h2&gt;

&lt;p&gt;When a target is missing, work top-down. The whole point is to recover the &lt;em&gt;exact input&lt;/em&gt; the rules saw, then replay the rules against it.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Dump the target labels, including _&lt;em&gt;meta&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;Prometheus exposes the pre-relabel discovery labels — the &lt;code&gt;__meta_*&lt;/code&gt; labels — but only for targets that survived relabeling, so a fully dropped target won't appear. The trick is to reload with the relabel rules temporarily removed (or pared down to a single permissive &lt;code&gt;keep&lt;/code&gt;), then read the raw discovery labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Show discovered labels for the job, including the __meta_* set the&lt;/span&gt;
&lt;span class="c"&gt;# relabel rules actually see as input.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'localhost:9090/api/v1/targets?state=active'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.data.activeTargets[]
        | select(.discoveredLabels.job=="api")
        | .discoveredLabels'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;discoveredLabels&lt;/code&gt; is the input to your &lt;code&gt;relabel_configs&lt;/code&gt;. &lt;code&gt;labels&lt;/code&gt; is the output. If a target is dropped entirely, you can also read the service discovery state directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:9090/api/v1/targets/metadata &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null  &lt;span class="c"&gt;# sanity check API is up&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'localhost:9090/service-discovery'&lt;/span&gt; &lt;span class="c"&gt;# the SD page shows pre-relabel labels&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Test the rules against those labels
&lt;/h3&gt;

&lt;p&gt;Now you have the input. Paste the &lt;code&gt;__meta_*&lt;/code&gt; labels and your &lt;code&gt;relabel_configs&lt;/code&gt; into &lt;a href="https://opscanopy.com/prometheus-relabel-tester/" rel="noopener noreferrer"&gt;the Prometheus Relabel Tester&lt;/a&gt; and run them. It applies the rules exactly the way Prometheus does — anchored regex, &lt;code&gt;;&lt;/code&gt; separator, &lt;code&gt;$1&lt;/code&gt;/&lt;code&gt;${1}&lt;/code&gt; expansion — and tells you, per label set, the resulting labels, which ones were added, changed, or removed, and whether the target was dropped (and by which rule).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bisect the rule list
&lt;/h3&gt;

&lt;p&gt;If you have a long chain, comment out the second half of the rules and re-run. If the target survives, the culprit is in the half you removed; if it still drops, it's in the half that's left. Halve again. Because relabeling is a deterministic top-to-bottom chain — each rule sees the output of the one before it — bisection converges fast, usually in two or three rounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example: the disappearing target, found and fixed
&lt;/h2&gt;

&lt;p&gt;Here's a real shape of this bug. You discover a pod, want to keep only opted-in pods, and route by environment. The target never shows up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_kubernetes_pod_annotation_prometheus_io_scrape&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
    &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_kubernetes_pod_label_env&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
    &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The discovery labels for the target you expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;__meta_kubernetes_pod_annotation_prometheus_io_scrape="true"
__meta_kubernetes_pod_label_app="api"
__meta_kubernetes_pod_label_env="production"
__address__="10.0.0.5:8080"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that input through the rules. The first &lt;code&gt;keep&lt;/code&gt; passes — &lt;code&gt;prometheus_io_scrape&lt;/code&gt; is exactly &lt;code&gt;"true"&lt;/code&gt;. The second &lt;code&gt;keep&lt;/code&gt; joins to &lt;code&gt;production&lt;/code&gt; and tries to match &lt;code&gt;^(?:prod)$&lt;/code&gt;. It doesn't. &lt;code&gt;production&lt;/code&gt; is not &lt;code&gt;prod&lt;/code&gt;, the regex is anchored, and &lt;code&gt;keep&lt;/code&gt; drops anything that fails to match. &lt;strong&gt;Rule 2 dropped the target.&lt;/strong&gt; The tester flags exactly that: dropped by rule 2, action &lt;code&gt;keep&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to match the real value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__meta_kubernetes_pod_label_env&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keep&lt;/span&gt;
    &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod.*&lt;/span&gt;       &lt;span class="c1"&gt;# ^(?:prod.*)$ — now matches "production"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-run. The target survives, carries &lt;code&gt;__address__="10.0.0.5:8080"&lt;/code&gt;, and appears in &lt;code&gt;/targets&lt;/code&gt;. Total time: under a minute, with no Prometheus reload and no waiting on a scrape interval.&lt;/p&gt;

&lt;p&gt;While you're cleaning up, the same chain often promotes pod labels and prunes discovery metadata. Note that &lt;code&gt;labelmap&lt;/code&gt; operates on label &lt;em&gt;names&lt;/em&gt;, copying matching labels to a new name, and &lt;code&gt;labeldrop&lt;/code&gt; removes labels whose names match — useful, but another place a label you wanted can quietly disappear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# Promote pod labels: __meta_kubernetes_pod_label_app="api" → app="api"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;labelmap&lt;/span&gt;
    &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__meta_kubernetes_pod_label_(.+)&lt;/span&gt;

  &lt;span class="c1"&gt;# Strip leftover discovery metadata before storage.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;labeldrop&lt;/span&gt;
    &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__meta_.+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Catch it before deploy
&lt;/h2&gt;

&lt;p&gt;The fastest debugging loop is the one that never reaches a live Prometheus. The reason relabeling is so easy to get wrong is that it fails silently: there's no parse error, no log line, just a target that isn't there. The only honest check is to run the rules against representative input and read the output — the same idea behind testing any behavioral config rather than trusting a schema lint.&lt;/p&gt;

&lt;p&gt;When you're staring at a "prometheus dropped target" mystery or a "prometheus label disappeared" report, grab the &lt;code&gt;discoveredLabels&lt;/code&gt; from the API, paste them with your rules into &lt;a href="https://opscanopy.com/prometheus-relabel-tester/" rel="noopener noreferrer"&gt;the Prometheus Relabel Tester&lt;/a&gt;, and watch which rule does the damage — it runs entirely in your browser, so internal scrape configs and target metadata never leave your tab.&lt;/p&gt;

&lt;p&gt;Once the labels are right, the rest of the observability chain follows. Break down a query that depends on those labels with &lt;a href="https://opscanopy.com/promql-explainer/" rel="noopener noreferrer"&gt;the PromQL Explainer&lt;/a&gt;, or confirm an alert on the resulting series lands in the right place with &lt;a href="https://opscanopy.com/alertmanager-route-tester/" rel="noopener noreferrer"&gt;the Alertmanager Route Tester&lt;/a&gt;. Shape the labels first; everything downstream depends on getting that step correct.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>observability</category>
      <category>relabeling</category>
    </item>
    <item>
      <title>Why Isnt My Alert Reaching the Right Receiver? Debugging Alertmanager Routing</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:28:17 +0000</pubDate>
      <link>https://dev.to/opscanopy/why-isnt-my-alert-reaching-the-right-receiver-debugging-alertmanager-routing-15o4</link>
      <guid>https://dev.to/opscanopy/why-isnt-my-alert-reaching-the-right-receiver-debugging-alertmanager-routing-15o4</guid>
      <description>&lt;p&gt;You shipped a new alerting rule, it fired in production, and the page went to the wrong team — or nobody got paged at all. The rule is correct and the alert is firing, yet your Alertmanager wrong-receiver problem is real: the notification landed somewhere you did not expect. When Alertmanager is not routing the way you intended, the bug is almost never in the alert. It is in the &lt;code&gt;route&lt;/code&gt; tree, and routing trees are code you cannot easily step through.&lt;/p&gt;

&lt;p&gt;Alertmanager dispatches every alert by walking a tree of routes. The root is the catch-all every alert enters; from there it descends into child routes whose matchers hold against the alert's labels. Get the walk wrong and the alert silently lands on the wrong leaf. This post covers the five bugs that cause it, and how to walk the tree yourself — no &lt;code&gt;amtool&lt;/code&gt;, no reload, no live instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: silent pages, or the wrong team gets paged
&lt;/h2&gt;

&lt;p&gt;Two shapes of the same problem. Either an alert you expected to page the database team went to a catch-all Slack channel nobody watches, or a &lt;code&gt;severity=critical&lt;/code&gt; alert produced no page at all. Both come from the same root cause: the route the alert &lt;em&gt;actually&lt;/em&gt; matched is not the route you &lt;em&gt;think&lt;/em&gt; it matched.&lt;/p&gt;

&lt;p&gt;Here is the tree most people start from — the canonical routing example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default-receiver'&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cluster'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;service'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
  &lt;span class="na"&gt;group_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
  &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4h&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-X-mails'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-DB-pages'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-Y-mails'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An alert with &lt;code&gt;service=database&lt;/code&gt; reaches &lt;code&gt;team-DB-pages&lt;/code&gt;. Simple enough — until the tree grows, siblings get reordered, someone adds a regex, and the walk stops doing what you read off the page. The fix is always the same: stop reasoning in your head and walk the tree against the exact labels the alert carries. Every bug below is a different way the walk surprises you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: first match wins and you forgot continue: true
&lt;/h2&gt;

&lt;p&gt;This is the most common Alertmanager-not-routing bug. Within a matched route, child routes are evaluated &lt;strong&gt;top to bottom&lt;/strong&gt;, and the alert descends into the &lt;strong&gt;first&lt;/strong&gt; matching child — then the sibling scan stops. Later siblings never run.&lt;/p&gt;

&lt;p&gt;That bites hardest when you want an alert to reach two receivers — say, every critical alert mirrored to an audit receiver &lt;em&gt;and&lt;/em&gt; routed to the owning team:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default-receiver'&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;all-critical-audit'&lt;/span&gt;
      &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity="critical"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-Y-pages'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fire an alert with &lt;code&gt;team=backend&lt;/code&gt; and &lt;code&gt;severity=critical&lt;/code&gt;. It matches the first sibling, &lt;code&gt;all-critical-audit&lt;/code&gt;, and the scan stops there. &lt;code&gt;team-Y-pages&lt;/code&gt; is never reached, so the backend team is never paged. The audit channel logged it, so it &lt;em&gt;looks&lt;/em&gt; like routing worked — which is exactly why this one is hard to spot.&lt;/p&gt;

&lt;p&gt;The fix is one line. A matched route with &lt;code&gt;continue: true&lt;/code&gt; does not stop the sibling scan, so the alert keeps falling through to later matching siblings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;all-critical-audit'&lt;/span&gt;
        &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity="critical"&lt;/span&gt;
        &lt;span class="na"&gt;continue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;        &lt;span class="c1"&gt;# keep going to later siblings&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-Y-pages'&lt;/span&gt;
        &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both fire. An alert can only reach more than one receiver when &lt;code&gt;continue: true&lt;/code&gt; is set on a matched route; without it, the first matching sibling always wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the matcher does not match (regex, quoting, a missing label)
&lt;/h2&gt;

&lt;p&gt;If the alert silently skips a route you were sure it would hit, the matcher probably is not matching. Three traps account for almost all of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regexes are fully anchored.&lt;/strong&gt; Both &lt;code&gt;match_re&lt;/code&gt; and the &lt;code&gt;=~&lt;/code&gt; / &lt;code&gt;!~&lt;/code&gt; operators wrap your pattern as &lt;code&gt;^(?:…)$&lt;/code&gt;. A partial pattern never matches a longer value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;env=~"staging"&lt;/span&gt;      &lt;span class="c1"&gt;# env=staging-eu does NOT match — anchored to exactly "staging"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;env=~"staging-.*"&lt;/span&gt;   &lt;span class="c1"&gt;# env=staging-eu matches now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A missing label is the empty string.&lt;/strong&gt; Alertmanager treats a label absent on the alert as &lt;code&gt;""&lt;/code&gt;, so &lt;code&gt;team=""&lt;/code&gt; matches an alert with &lt;em&gt;no&lt;/em&gt; &lt;code&gt;team&lt;/code&gt; label and &lt;code&gt;team!=""&lt;/code&gt; requires it to be present and non-empty. If you write &lt;code&gt;match: { team: frontend }&lt;/code&gt; but the alert never sets a &lt;code&gt;team&lt;/code&gt; label, the matcher compares &lt;code&gt;frontend&lt;/code&gt; against &lt;code&gt;""&lt;/code&gt;, fails, and the route is skipped — you fall through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operators and quoting in &lt;code&gt;matchers:&lt;/code&gt; strings.&lt;/strong&gt; The modern &lt;code&gt;matchers:&lt;/code&gt; form takes strings like &lt;code&gt;foo="bar"&lt;/code&gt;, &lt;code&gt;foo=~"re"&lt;/code&gt;, &lt;code&gt;foo!="x"&lt;/code&gt;, and &lt;code&gt;foo!~"re"&lt;/code&gt;; the value can be quoted or bare. The two-character operators (&lt;code&gt;=~&lt;/code&gt;, &lt;code&gt;!~&lt;/code&gt;, &lt;code&gt;!=&lt;/code&gt;) are matched before the single &lt;code&gt;=&lt;/code&gt;, so &lt;code&gt;severity!="info"&lt;/code&gt; parses as a not-equal. Get the quoting wrong — leave a quote open, say — and the matcher is invalid; an invalid matcher cannot hold, so the route is skipped.&lt;/p&gt;

&lt;p&gt;Here is a matcher route that combines a regex with an inequality:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default-receiver'&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;env'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;staging-slack'&lt;/span&gt;
      &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;env=~"staging-.*"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity!="info"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prod-pager'&lt;/span&gt;
      &lt;span class="na"&gt;match_re&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prod-.*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All matchers on a route must hold for it to match — it is a logical AND. An alert with &lt;code&gt;env=staging-eu&lt;/code&gt; and &lt;code&gt;severity=warning&lt;/code&gt; reaches &lt;code&gt;staging-slack&lt;/code&gt;: the anchored &lt;code&gt;staging-.*&lt;/code&gt; matches and &lt;code&gt;severity&lt;/code&gt; is not &lt;code&gt;info&lt;/code&gt;. Change &lt;code&gt;severity&lt;/code&gt; to &lt;code&gt;info&lt;/code&gt; and the second matcher fails, so the whole route is skipped.&lt;/p&gt;

&lt;p&gt;If your alert rules carry the wrong labels in the first place — or are missing the ones your routes match on — fix that upstream. The &lt;a href="https://opscanopy.com/prometheus-relabel-tester/" rel="noopener noreferrer"&gt;Prometheus Relabel Tester&lt;/a&gt; previews exactly what labels survive your relabel rules before they ever reach the route tree.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa9d678grdp4bzwu0if3f.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa9d678grdp4bzwu0if3f.webp" alt="Synthwave illustration of debugging Alertmanager routing: an alert rides a neon route tree through a first-match-wins gate, past the classic traps — missing continue, matcher regex, catch-all defaults" width="799" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: a catch-all default route swallows everything before your route is reached
&lt;/h2&gt;

&lt;p&gt;An Alertmanager catch-all route is supposed to be a safety net — the receiver that fires when nothing more specific matches. But a catch-all placed &lt;em&gt;above&lt;/em&gt; a specific sibling, instead of below it, turns into a trap. Combined with first-match-wins, a broad rule at the top shadows every specific rule beneath it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Trap: the broad rule above shadows the specific one&lt;/span&gt;
&lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;catch-all&lt;/span&gt;
    &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity=~".*"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# matches everything&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-pager&lt;/span&gt;             &lt;span class="c1"&gt;# NEVER reached&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;severity=~".*"&lt;/code&gt; matches any alert that has a &lt;code&gt;severity&lt;/code&gt; label (anchored, but &lt;code&gt;.*&lt;/code&gt; covers the whole value). It is the first sibling, so the scan stops there — &lt;code&gt;db-pager&lt;/code&gt; is dead code. The database team never pages.&lt;/p&gt;

&lt;p&gt;There are two correct ways to think about a catch-all. Either put your specific routes first and the broad one last:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Fix: specific first, broad last&lt;/span&gt;
&lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-pager&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;catch-all&lt;/span&gt;
    &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity=~".*"'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or rely on the real catch-all you already have — the root route's own &lt;code&gt;receiver&lt;/code&gt;. When no child route matches, the route the alert is sitting in becomes the terminal match and &lt;em&gt;its&lt;/em&gt; receiver fires. The root always sets a default &lt;code&gt;receiver&lt;/code&gt;, so an alert that matches no child still lands somewhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default-receiver'&lt;/span&gt;     &lt;span class="c1"&gt;# the true catch-all&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-X-mails'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;frontend&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-Y-mails'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;backend&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An alert with &lt;code&gt;team=platform&lt;/code&gt; matches neither child. It does not error and it does not vanish — it falls through to &lt;code&gt;default-receiver&lt;/code&gt;, the catch-all working as intended. The "why didn't my alert route?" cases are usually this: it &lt;em&gt;did&lt;/em&gt; route, straight to the default, because no child matched. If a route resolves to no receiver at all, that is a genuine misconfiguration — Alertmanager requires the root to set a default &lt;code&gt;receiver&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: route ordering among siblings
&lt;/h2&gt;

&lt;p&gt;Bug 3 is a catch-all swallowing everything. Bug 4 is the subtler, more general version: among siblings, order &lt;em&gt;always&lt;/em&gt; decides which single route wins, even when both are specific. Because only the first matching sibling is taken (absent &lt;code&gt;continue&lt;/code&gt;), two overlapping matchers in the wrong order route the alert to the wrong team.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fdebug-alertmanager-routing-diagram.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fdebug-alertmanager-routing-diagram.svg" alt="A misrouted alert: on the left the alert hits the route tree and lands on the wrong receiver in red because continue is missing, on the right the corrected tree routes it to the right receiver in green" width="1200" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Consider an alert that is both a database alert and a backend-team alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# labels: service=database, team=backend, severity=critical&lt;/span&gt;
&lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-Y-pages'&lt;/span&gt;      &lt;span class="c1"&gt;# matches team=backend&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;backend&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team-DB-pages'&lt;/span&gt;     &lt;span class="c1"&gt;# matches service=database&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both routes' matchers hold against this alert. Order breaks the tie: &lt;code&gt;team-Y-pages&lt;/code&gt; is first, so it wins, and the database on-call (&lt;code&gt;team-DB-pages&lt;/code&gt;) is never reached. Swap the two and the database route wins instead. Neither matcher is wrong — the &lt;em&gt;order&lt;/em&gt; is the bug.&lt;/p&gt;

&lt;p&gt;When two siblings can legitimately both match, you have three choices: put the one you want to win first, make the matchers mutually exclusive (add &lt;code&gt;service!=database&lt;/code&gt; to the backend route, say), or set &lt;code&gt;continue: true&lt;/code&gt; on the first so the alert reaches both. Nesting helps too — a parent matches the broad case and narrows it with children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default-receiver'&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;web-team'&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
      &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;instance'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;web-team-pager'&lt;/span&gt;
          &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity="critical"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;web-team-slack'&lt;/span&gt;
          &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity=~"warning|info"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An alert with &lt;code&gt;service=web&lt;/code&gt; first descends into &lt;code&gt;web-team&lt;/code&gt;, then the nested children pick the receiver by &lt;code&gt;severity&lt;/code&gt;. A &lt;code&gt;severity=critical&lt;/code&gt; web alert walks &lt;code&gt;root → web-team → web-team-pager&lt;/code&gt;. The descent is explicit, so order surprises stay local to one small sibling list instead of hiding across the whole tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: grouping makes an alert look missing when it is just batched
&lt;/h2&gt;

&lt;p&gt;Sometimes the alert routed perfectly and you still think it is missing — because grouping batched it with others and the notification has not been sent &lt;em&gt;yet&lt;/em&gt;. Grouping is controlled by &lt;code&gt;group_by&lt;/code&gt;, &lt;code&gt;group_wait&lt;/code&gt;, &lt;code&gt;group_interval&lt;/code&gt;, and &lt;code&gt;repeat_interval&lt;/code&gt;, and all four are &lt;strong&gt;inherited&lt;/strong&gt; down the tree. A child that does not set its own carries the parent's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cluster'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-pager&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;# no group_by here → INHERITS ['alertname', 'cluster']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;db-pager&lt;/code&gt; leaf has no &lt;code&gt;group_by&lt;/code&gt; of its own, so it inherits &lt;code&gt;['alertname', 'cluster']&lt;/code&gt; and a 30s &lt;code&gt;group_wait&lt;/code&gt; from the root. Two consequences trip people up. First, a new group is held for &lt;code&gt;group_wait&lt;/code&gt; before its first notification — so a freshly fired alert that "isn't paging" may just be inside its wait window. Second, if &lt;code&gt;group_by&lt;/code&gt; is too coarse, your alert gets folded into an existing group's notification and looks like it never fired separately.&lt;/p&gt;

&lt;p&gt;Override only where a subtree actually needs different grouping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cluster'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-pager&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;alertname'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cluster'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;database'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leaf you are reading is not necessarily the grouping that applies. Always resolve the &lt;em&gt;effective&lt;/em&gt; &lt;code&gt;group_by&lt;/code&gt; — the value inherited from the nearest ancestor that set it — before you conclude an alert is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test Alertmanager routing without amtool: walk the tree against the alerts labels
&lt;/h2&gt;

&lt;p&gt;You do not need &lt;code&gt;amtool config routes test&lt;/code&gt;, and you do not need to reload a live Alertmanager to do route debugging. The routing walk is deterministic, so you can do it by hand. Take the exact labels off the firing alert and walk the tree top-down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The labels the alert actually carries (from the Alertmanager UI or API):&lt;/span&gt;
&lt;span class="nv"&gt;alertname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;HighLatency
&lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;database
&lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;backend
&lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;critical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, starting at the root:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enter the root.&lt;/strong&gt; Every alert does — it is the catch-all. Note its &lt;code&gt;receiver&lt;/code&gt; and &lt;code&gt;group_by&lt;/code&gt; as the inheritance baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan children top to bottom.&lt;/strong&gt; For each child, check whether &lt;em&gt;all&lt;/em&gt; its matchers hold against the labels. Remember: regexes are anchored, and a missing label is &lt;code&gt;""&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Descend into the first match.&lt;/strong&gt; That child's subtree is now where you are. If it set &lt;code&gt;continue: true&lt;/code&gt;, keep scanning its later siblings too — those become additional matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If no child matches, you are done.&lt;/strong&gt; The current route is the terminal match; its inherited &lt;code&gt;receiver&lt;/code&gt; fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve inheritance at the leaf.&lt;/strong&gt; The effective &lt;code&gt;receiver&lt;/code&gt; and &lt;code&gt;group_by&lt;/code&gt; come from the nearest ancestor that set them, not necessarily the leaf.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do that for the labels above against the docs tree and you land on &lt;code&gt;team-DB-pages&lt;/code&gt; via &lt;code&gt;service=database&lt;/code&gt;, inheriting &lt;code&gt;group_by&lt;/code&gt; from the root. Doing this walk on paper for a 40-node tree is exactly the error-prone reasoning that produced the bug in the first place — which is the whole reason a tester exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the matching receiver now: an Alertmanager route debugger in the browser
&lt;/h2&gt;

&lt;p&gt;When the tree is more than a few nodes, walk it with the &lt;a href="https://opscanopy.com/alertmanager-route-tester/" rel="noopener noreferrer"&gt;Alertmanager Route Tester&lt;/a&gt; instead of in your head. Paste your route tree — a bare &lt;code&gt;route:&lt;/code&gt; block or a full &lt;code&gt;alertmanager.yml&lt;/code&gt;, of which only the &lt;code&gt;route&lt;/code&gt; block is read — and the sample alert's labels, one &lt;code&gt;key=value&lt;/code&gt; per line. It reproduces the semantics exactly: first-match-wins, &lt;code&gt;continue: true&lt;/code&gt; fan-out, anchored regexes, missing-label-as-empty-string, and grouping inheritance.&lt;/p&gt;

&lt;p&gt;What you get back is every receiver the alert reaches, in evaluation order, each with its route-path breadcrumb from the root down to the matched node, a tag on any match reached only via &lt;code&gt;continue&lt;/code&gt;, and the effective &lt;code&gt;group_by&lt;/code&gt; after inheritance. It is a dry run of dispatch — no notification is sent, nothing is uploaded, and it all runs in your browser, so you can safely paste internal receiver names and private team labels.&lt;/p&gt;

&lt;p&gt;Once the labels are confirmed correct at the source with the &lt;a href="https://opscanopy.com/prometheus-relabel-tester/" rel="noopener noreferrer"&gt;Prometheus Relabel Tester&lt;/a&gt; and your rules are proven to fire with &lt;a href="https://opscanopy.com/loki-alert-rule-tester/" rel="noopener noreferrer"&gt;AlertLint&lt;/a&gt;, the route tree is the last hop to get right. Walk it before it pages anyone — and the next time an alert reaches the wrong receiver, you will know which node sent it there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opscanopy.com/alertmanager-route-tester/" rel="noopener noreferrer"&gt;Open the Alertmanager Route Tester →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>alertmanager</category>
      <category>observability</category>
      <category>alerting</category>
    </item>
    <item>
      <title>How to Convert a docker run Command to docker-compose.yml</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:25:53 +0000</pubDate>
      <link>https://dev.to/opscanopy/how-to-convert-a-docker-run-command-to-docker-composeyml-bjf</link>
      <guid>https://dev.to/opscanopy/how-to-convert-a-docker-run-command-to-docker-composeyml-bjf</guid>
      <description>&lt;p&gt;You started a container with a quick &lt;code&gt;docker run&lt;/code&gt; line during a debugging session. It worked. Now someone wants it in the repo, reviewable in a PR, startable with one command — and you have a 200-character one-liner with &lt;code&gt;-p&lt;/code&gt;, three &lt;code&gt;-v&lt;/code&gt; mounts, half a dozen &lt;code&gt;-e&lt;/code&gt; flags and a &lt;code&gt;--restart&lt;/code&gt; policy that you now need to convert to &lt;code&gt;docker-compose.yml&lt;/code&gt;. This is the moment most people reach for the docs and start hand-translating, and it's exactly where flags get dropped, ports get quoted wrong, and lists get mis-nested.&lt;/p&gt;

&lt;p&gt;This guide walks through how to convert a &lt;code&gt;docker run&lt;/code&gt; command to &lt;code&gt;docker-compose.yml&lt;/code&gt; flag by flag, including the gotchas that bite when you do it by hand. Every mapping here matches what the &lt;a href="https://opscanopy.com/docker-run-to-compose/" rel="noopener noreferrer"&gt;Docker Run to Compose converter&lt;/a&gt; actually emits, so you can read the rules and then paste your command into the tool to skip the mechanical part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why move from docker run to Compose
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;docker run&lt;/code&gt; command is a fine way to start one container interactively. It stops being fine the moment any of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The exact invocation needs to live in version control so a teammate can reproduce it.&lt;/li&gt;
&lt;li&gt;You want it reviewed — a YAML diff is far easier to read in a PR than a wall of flags on one line.&lt;/li&gt;
&lt;li&gt;The container has dependencies, or you'll soon add a second service.&lt;/li&gt;
&lt;li&gt;You want &lt;code&gt;docker compose up -d&lt;/code&gt; instead of remembering the full command every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compose doesn't change what the container does. It just gives the same configuration a declarative, diffable shape. The translation is almost entirely mechanical — which is precisely why it's worth getting the rules straight rather than eyeballing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anatomy of a docker run command
&lt;/h2&gt;

&lt;p&gt;Here's a real one. Postgres, published port, named volume, two environment variables, a restart policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5432:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; pgdata:/var/lib/postgresql/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read left to right, a &lt;code&gt;docker run&lt;/code&gt; command is three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker run&lt;/code&gt;&lt;/strong&gt; — the command prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flags&lt;/strong&gt; — everything starting with &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;--&lt;/code&gt;, in any order. Each flag either takes a value (&lt;code&gt;-p 5432:5432&lt;/code&gt;) or is a boolean (&lt;code&gt;-d&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image, then the command&lt;/strong&gt; — the &lt;em&gt;first&lt;/em&gt; token that isn't a flag is the image (&lt;code&gt;postgres:16&lt;/code&gt;). Anything after it is the command to run inside the container, passed verbatim.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That ordering matters. As soon as the parser hits the image, flag scanning stops — &lt;code&gt;docker run ... postgres:16 -p 80:80&lt;/code&gt; treats &lt;code&gt;-p 80:80&lt;/code&gt; as arguments to the container, not as a published port. Keep your flags &lt;em&gt;before&lt;/em&gt; the image.&lt;/p&gt;

&lt;p&gt;Bundled short flags are the other thing to know about. &lt;code&gt;-it&lt;/code&gt; is two flags (&lt;code&gt;-i&lt;/code&gt; and &lt;code&gt;-t&lt;/code&gt;), and &lt;code&gt;-itp 8080:80&lt;/code&gt; is three: &lt;code&gt;-i&lt;/code&gt;, &lt;code&gt;-t&lt;/code&gt;, and &lt;code&gt;-p 8080:80&lt;/code&gt;. A value-taking flag like &lt;code&gt;-p&lt;/code&gt; consumes the rest of the bundle (or the next token) as its argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping every docker run flag to docker-compose.yml
&lt;/h2&gt;

&lt;p&gt;This is the core of converting a &lt;code&gt;docker run&lt;/code&gt; command to compose: each flag maps to a key under the service. Here's the full mapping table for the flags you'll actually hit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fconvert-docker-run-to-compose-diagram.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fconvert-docker-run-to-compose-diagram.svg" alt="A mapping showing docker run flags on the left connected by arrows to their docker-compose.yml keys on the right" width="1200" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;docker run&lt;/code&gt; flag&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; key&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-p&lt;/code&gt; / &lt;code&gt;--publish&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quoted string, e.g. &lt;code&gt;"8080:80"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-v&lt;/code&gt; / &lt;code&gt;--volume&lt;/code&gt;, &lt;code&gt;--mount&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;volumes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Short &lt;code&gt;source:target[:ro]&lt;/code&gt; form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-e&lt;/code&gt; / &lt;code&gt;--env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;environment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KEY=value&lt;/code&gt; list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--env-file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;env_file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One or more files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;container_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Also becomes the service key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--restart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;no&lt;/code&gt; / &lt;code&gt;always&lt;/code&gt; / &lt;code&gt;on-failure&lt;/code&gt; / &lt;code&gt;unless-stopped&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--network&lt;/code&gt; / &lt;code&gt;--net&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;networks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;host&lt;/code&gt; / &lt;code&gt;none&lt;/code&gt; → &lt;code&gt;network_mode&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-w&lt;/code&gt; / &lt;code&gt;--workdir&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;working_dir&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-u&lt;/code&gt; / &lt;code&gt;--user&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--cap-add&lt;/code&gt; / &lt;code&gt;--cap-drop&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cap_add&lt;/code&gt; / &lt;code&gt;cap_drop&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--add-host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extra_hosts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--entrypoint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;entrypoint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--privileged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;privileged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-m&lt;/code&gt; / &lt;code&gt;--memory&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mem_limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--cpus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cpus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-l&lt;/code&gt; / &lt;code&gt;--label&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;labels&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--health-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;healthcheck&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cmd&lt;/code&gt; / &lt;code&gt;interval&lt;/code&gt; / &lt;code&gt;timeout&lt;/code&gt; / &lt;code&gt;retries&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-i&lt;/code&gt; / &lt;code&gt;-t&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stdin_open&lt;/code&gt; / &lt;code&gt;tty&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--rm&lt;/code&gt;, &lt;code&gt;-d&lt;/code&gt; / &lt;code&gt;--detach&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No Compose equivalent (dropped)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few of these deserve a closer look.&lt;/p&gt;

&lt;h3&gt;
  
  
  -p → ports
&lt;/h3&gt;

&lt;p&gt;Each &lt;code&gt;-p&lt;/code&gt; becomes one entry under &lt;code&gt;ports:&lt;/code&gt;, written as a &lt;strong&gt;quoted&lt;/strong&gt; &lt;code&gt;"HOST:CONTAINER"&lt;/code&gt; string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quotes are not optional. A bare &lt;code&gt;5432:5432&lt;/code&gt; is read by YAML 1.1 parsers as a sexagesimal (base-60) number, which silently corrupts the mapping. This is one of the most common hand-conversion bugs, so always quote ports.&lt;/p&gt;

&lt;h3&gt;
  
  
  -v / --volume and --mount → volumes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;-v&lt;/code&gt; keeps its short &lt;code&gt;source:target[:ro]&lt;/code&gt; form verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/data:/usr/share/nginx/html:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A long-form &lt;code&gt;--mount type=bind,source=/data,target=/app,readonly&lt;/code&gt; is reduced to the same short &lt;code&gt;source:target:ro&lt;/code&gt; shape. Named volumes and bind mounts are preserved exactly as written — converting doesn't invent a top-level &lt;code&gt;volumes:&lt;/code&gt; declaration you didn't ask for (more on that in the gotchas).&lt;/p&gt;

&lt;h3&gt;
  
  
  -e / --env-file → environment / env_file
&lt;/h3&gt;

&lt;p&gt;Each &lt;code&gt;-e KEY=value&lt;/code&gt; becomes a &lt;code&gt;KEY=value&lt;/code&gt; line under &lt;code&gt;environment:&lt;/code&gt;, and each &lt;code&gt;--env-file&lt;/code&gt; maps to &lt;code&gt;env_file:&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=secret&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=app&lt;/span&gt;
&lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you're moving environment off the command line and into files, it's worth confirming your &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.example&lt;/code&gt; haven't drifted apart — the &lt;a href="https://opscanopy.com/env-example-checker/" rel="noopener noreferrer"&gt;Env Example Checker&lt;/a&gt; flags keys that exist in one but not the other so a missing variable doesn't surface as a runtime error.&lt;/p&gt;

&lt;h3&gt;
  
  
  --restart, --name, -w, -u
&lt;/h3&gt;

&lt;p&gt;These are direct one-to-one scalar mappings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
&lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/work&lt;/span&gt;
&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1000:1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--name&lt;/code&gt; does double duty: it sets &lt;code&gt;container_name&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; becomes the service key (&lt;code&gt;services: { db: ... }&lt;/code&gt;). When there's no &lt;code&gt;--name&lt;/code&gt;, the service is keyed as &lt;code&gt;app&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  --network → networks (or network_mode)
&lt;/h3&gt;

&lt;p&gt;A named network becomes a &lt;code&gt;networks:&lt;/code&gt; list entry. But &lt;code&gt;host&lt;/code&gt; and &lt;code&gt;none&lt;/code&gt; are special — they're network &lt;em&gt;modes&lt;/em&gt;, not networks, so they map to &lt;code&gt;network_mode&lt;/code&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker run --network backend&lt;/span&gt;
&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;

&lt;span class="c1"&gt;# docker run --network host&lt;/span&gt;
&lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  --cap-add, --cap-drop, --add-host
&lt;/h3&gt;

&lt;p&gt;Capabilities and host entries each collect into a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NET_ADMIN&lt;/span&gt;
&lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;
&lt;span class="na"&gt;extra_hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db:10.0.0.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  --health-* → healthcheck
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;--health-*&lt;/code&gt; flags assemble into a single &lt;code&gt;healthcheck:&lt;/code&gt; block. The command becomes a &lt;code&gt;CMD-SHELL&lt;/code&gt; test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--health-cmd&lt;/span&gt; &lt;span class="s2"&gt;"redis-cli ping"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--health-interval&lt;/span&gt; 10s &lt;span class="nt"&gt;--health-timeout&lt;/span&gt; 3s &lt;span class="nt"&gt;--health-retries&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
  redis:7-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;redis-cli&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  -m / --cpus → mem_limit / cpus
&lt;/h3&gt;

&lt;p&gt;Resource flags map to &lt;code&gt;mem_limit&lt;/code&gt; and &lt;code&gt;cpus&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256m&lt;/span&gt;
&lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the v2-style limits Compose honors directly. When you eventually move this container to Kubernetes, those numbers become pod requests and limits — the &lt;a href="https://opscanopy.com/kubernetes-resource-calculator/" rel="noopener noreferrer"&gt;Kubernetes Resource Calculator&lt;/a&gt; turns a memory and CPU figure into safe &lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;limits&lt;/code&gt; values so you're not guessing at the conversion.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flags with no Compose equivalent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--rm&lt;/code&gt; and &lt;code&gt;-d&lt;/code&gt; / &lt;code&gt;--detach&lt;/code&gt; describe how &lt;em&gt;you&lt;/em&gt; invoked the container, not how it's configured, so they have no place in a service definition. They're dropped — but you should know why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--rm&lt;/code&gt; (remove on exit) is irrelevant because Compose manages the lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-d&lt;/code&gt; / &lt;code&gt;--detach&lt;/code&gt; is replaced by how you start the stack: &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9aewsniz9kzvc24ua97u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9aewsniz9kzvc24ua97u.webp" alt="Illustration: a docker run command on one retro terminal, its flags flowing screen to screen until they reassemble as a docker-compose.yml service" width="799" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A full worked example
&lt;/h2&gt;

&lt;p&gt;Take this longer command — an API service on a user network, with environment, capabilities and an extra host entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt; backend &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-add&lt;/span&gt; NET_ADMIN &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-drop&lt;/span&gt; ALL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-host&lt;/span&gt; db:10.0.0.5 &lt;span class="se"&gt;\&lt;/span&gt;
  myorg/api:1.4.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying the mapping table flag by flag — and dropping &lt;code&gt;-d&lt;/code&gt; with a note — gives this service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myorg/api:1.4.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NODE_ENV=production&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
    &lt;span class="na"&gt;extra_hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db:10.0.0.5&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NET_ADMIN&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what carried over and what didn't. &lt;code&gt;--name api&lt;/code&gt; set both the service key and &lt;code&gt;container_name&lt;/code&gt;. The port is quoted. &lt;code&gt;-d&lt;/code&gt; is gone — you'll start this with &lt;code&gt;docker compose up -d&lt;/code&gt;. Everything else is a direct flag-to-key translation in the fixed, readable order Compose conventions expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas when you convert docker run -p -v -e to compose
&lt;/h2&gt;

&lt;p&gt;The mapping is mechanical, but a handful of details trip people up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named volumes vs bind mounts.&lt;/strong&gt; Both use the same &lt;code&gt;-v&lt;/code&gt; syntax, so they land in the same &lt;code&gt;volumes:&lt;/code&gt; list — but they mean different things. &lt;code&gt;-v /data:/app&lt;/code&gt; is a &lt;em&gt;bind mount&lt;/em&gt; of a host path; &lt;code&gt;-v pgdata:/app&lt;/code&gt; is a &lt;em&gt;named volume&lt;/em&gt; managed by Docker. A bare relative or absolute path with a leading &lt;code&gt;/&lt;/code&gt; (or &lt;code&gt;.&lt;/code&gt;) is a bind mount; a bare name is a volume. Converting keeps the string exactly as written and does &lt;strong&gt;not&lt;/strong&gt; synthesize the top-level &lt;code&gt;volumes:&lt;/code&gt; block that named volumes technically need. Compose will create an anonymous-ish volume implicitly, but if you want it explicit and shareable, add it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Environment precedence.&lt;/strong&gt; If you use both &lt;code&gt;environment&lt;/code&gt; and &lt;code&gt;env_file&lt;/code&gt;, values set inline in &lt;code&gt;environment&lt;/code&gt; win over the same key in an env file. And neither overrides a variable that's already set in the shell when you run &lt;code&gt;docker compose up&lt;/code&gt; unless you reference it. Keep secrets out of &lt;code&gt;environment:&lt;/code&gt; (it's committed) and in &lt;code&gt;env_file:&lt;/code&gt; (gitignored) — and verify the file's keys with the &lt;a href="https://opscanopy.com/env-example-checker/" rel="noopener noreferrer"&gt;Env Example Checker&lt;/a&gt; before you ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;host and none network modes.&lt;/strong&gt; As covered above, &lt;code&gt;--network host&lt;/code&gt; and &lt;code&gt;--network none&lt;/code&gt; are not networks — they're modes. Putting &lt;code&gt;host&lt;/code&gt; under a &lt;code&gt;networks:&lt;/code&gt; list is invalid; it has to be &lt;code&gt;network_mode: host&lt;/code&gt;. This is the kind of thing easy to miss by hand because the flag spelling is identical to a normal &lt;code&gt;--network backend&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ports: publish vs expose.&lt;/strong&gt; &lt;code&gt;-p&lt;/code&gt; &lt;em&gt;publishes&lt;/em&gt; a port to the host (&lt;code&gt;ports:&lt;/code&gt;), which is what you almost always mean. There is no &lt;code&gt;-p&lt;/code&gt;-without-a-host-side equivalent of Compose's &lt;code&gt;expose:&lt;/code&gt; (container-to-container only, no host binding) — &lt;code&gt;expose&lt;/code&gt; comes from the image's &lt;code&gt;EXPOSE&lt;/code&gt; directive or an explicit &lt;code&gt;expose:&lt;/code&gt; key, not from &lt;code&gt;docker run -p&lt;/code&gt;. Don't reach for &lt;code&gt;expose:&lt;/code&gt; when converting a &lt;code&gt;-p&lt;/code&gt; flag; you want &lt;code&gt;ports:&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convert it instantly
&lt;/h2&gt;

&lt;p&gt;The rules above are everything you need to do this by hand. But hand-conversion is exactly where a flag gets dropped, a port loses its quotes, or &lt;code&gt;--network host&lt;/code&gt; ends up in the wrong key — and you don't find out until the container behaves differently than the original command.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://opscanopy.com/docker-run-to-compose/" rel="noopener noreferrer"&gt;Docker Run to Compose converter&lt;/a&gt; does the mechanical translation for you. It tokenizes the command the way a shell would — honoring quotes, bundled short flags like &lt;code&gt;-it&lt;/code&gt;, and backslash-newline continuations — maps each flag onto the matching Compose key, and emits deterministic YAML. Flags with no equivalent (&lt;code&gt;--rm&lt;/code&gt;, &lt;code&gt;-d&lt;/code&gt;) come back as warnings instead of silently vanishing, so nothing disappears without you knowing. It also runs in reverse: paste a Compose service and get an equivalent &lt;code&gt;docker run&lt;/code&gt; line back.&lt;/p&gt;

&lt;p&gt;Everything happens in your browser, so you can paste commands that name private registries or carry secret-bearing &lt;code&gt;-e&lt;/code&gt; values without anything leaving the tab. Paste your command, read the warnings, and commit the result.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>dockercompose</category>
      <category>containers</category>
    </item>
    <item>
      <title>7 Common .gitlab-ci.yml Mistakes (and How to Catch Them)</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:25:48 +0000</pubDate>
      <link>https://dev.to/opscanopy/7-common-gitlab-ciyml-mistakes-and-how-to-catch-them-3ka1</link>
      <guid>https://dev.to/opscanopy/7-common-gitlab-ciyml-mistakes-and-how-to-catch-them-3ka1</guid>
      <description>&lt;p&gt;You push a one-line change, switch tabs, and 30 seconds later the pipeline icon goes red. Not a failing test — the pipeline never started. GitLab printed &lt;code&gt;This GitLab CI configuration is invalid&lt;/code&gt; and a single terse line about a stage or a script. You re-read the YAML three times, find the typo, push again, wait again. Most of the GitLab CI mistakes that cost you that round-trip are not exotic. They are the same handful of GitLab pipeline misconfigurations, repeated across every team: a stage that was never declared, a job that does nothing, a &lt;code&gt;needs&lt;/code&gt; that points at a job you renamed.&lt;/p&gt;

&lt;p&gt;The good news is that these GitLab CI YAML errors are structural, which means they are catchable before you commit. Below are the seven that show up most often, each with the symptom, a minimal broken example, and the fix you can paste in.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Referencing an undefined stage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;

&lt;span class="na"&gt;release-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;      &lt;span class="c1"&gt;# not in stages:&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;make release&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitLab rejects this with something like &lt;code&gt;chosen stage release does not exist; available stages are .pre, build, test, .post&lt;/code&gt;. A job's &lt;code&gt;stage:&lt;/code&gt; has to be one of the names in your top-level &lt;code&gt;stages:&lt;/code&gt; list — or one of the five implicit stages GitLab always provides: &lt;code&gt;.pre&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt;, &lt;code&gt;deploy&lt;/code&gt;, and &lt;code&gt;.post&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is a quieter version of this bug. A job with no &lt;code&gt;stage:&lt;/code&gt; at all defaults to &lt;code&gt;test&lt;/code&gt;. If you declared a custom &lt;code&gt;stages:&lt;/code&gt; list that does not include &lt;code&gt;test&lt;/code&gt;, that job has nowhere to run and GitLab errors the same way. The fix is the same in both cases — declare the stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;

&lt;span class="na"&gt;release-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;make release&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. A job with no script (and the global/default-script confusion)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;

&lt;span class="na"&gt;empty-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="c1"&gt;# no script, run, trigger, or extends&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces the GitLab CI job without script error — &lt;code&gt;job config should implement a script: or a trigger: keyword&lt;/code&gt;. A visible job has to &lt;em&gt;do&lt;/em&gt; something. There are exactly four ways to satisfy that: run commands with &lt;code&gt;script:&lt;/code&gt; (or the newer &lt;code&gt;run:&lt;/code&gt;), start a downstream pipeline with &lt;code&gt;trigger:&lt;/code&gt;, or inherit one of those from somewhere else via &lt;code&gt;extends:&lt;/code&gt;. A job with none of the four is rejected.&lt;/p&gt;

&lt;p&gt;The confusion that causes this is the global/default block. Teams set a &lt;code&gt;before_script:&lt;/code&gt; or a &lt;code&gt;default:&lt;/code&gt; section and assume a job inherits a &lt;em&gt;command&lt;/em&gt; from it. It does not. &lt;code&gt;before_script&lt;/code&gt; runs &lt;em&gt;around&lt;/em&gt; your script; it is not the script. &lt;code&gt;default:&lt;/code&gt; supplies defaults for keys like &lt;code&gt;image:&lt;/code&gt; and &lt;code&gt;cache:&lt;/code&gt;, but it does not give a job an executable surface. The job still needs its own &lt;code&gt;script:&lt;/code&gt; (or a &lt;code&gt;trigger&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, or &lt;code&gt;extends&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;empty-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;make check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hidden, dot-prefixed templates are the exception — more on those in mistake six. They are allowed to be partial fragments, so they are not required to carry a script.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcb6gbsr7ulbm10zw6c76.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcb6gbsr7ulbm10zw6c76.webp" alt="Synthwave illustration: a magnifying glass examining a .gitlab-ci.yml on a retro CRT monitor, surrounded by numbered neon icons of common pipeline mistakes — a broken puzzle piece, a cracked shield, gears, and an hourglass" width="799" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. needs pointing at a job in a later stage or a job that does not exist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;

&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make&lt;/span&gt;

&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;compile&lt;/span&gt;      &lt;span class="c1"&gt;# no such job&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;needs:&lt;/code&gt; builds the directed acyclic graph that lets jobs start early instead of waiting for a whole stage to finish. Every name in it has to resolve to a real job in the same pipeline. Here &lt;code&gt;compile&lt;/code&gt; was renamed to &lt;code&gt;build&lt;/code&gt; at some point and the &lt;code&gt;needs&lt;/code&gt; reference was never updated, so the graph has a dangling edge and the pipeline fails to assemble.&lt;/p&gt;

&lt;p&gt;The classic version of this mistake is ordering: pointing &lt;code&gt;needs&lt;/code&gt; at a job in a &lt;em&gt;later&lt;/em&gt; stage. &lt;code&gt;needs&lt;/code&gt; can only reference jobs that run before — a job cannot need something that hasn't run yet. Point it at the real upstream job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule applies to &lt;code&gt;dependencies:&lt;/code&gt;. Every artifact dependency you list has to name a job that actually exists, or the download fails at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. rules that never match (or always do) — and mixing only/except with rules
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sometimes&lt;/span&gt;        &lt;span class="c1"&gt;# not a valid when value&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$CI_COMMIT_TAG'&lt;/span&gt; &lt;span class="c1"&gt;# rules must be a list&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two GitLab CI rules and extends errors are packed into this one job. First, &lt;code&gt;when:&lt;/code&gt; only accepts a fixed set of values — &lt;code&gt;on_success&lt;/code&gt;, &lt;code&gt;on_failure&lt;/code&gt;, &lt;code&gt;always&lt;/code&gt;, &lt;code&gt;manual&lt;/code&gt;, &lt;code&gt;delayed&lt;/code&gt;, or &lt;code&gt;never&lt;/code&gt;. &lt;code&gt;sometimes&lt;/code&gt; is not one of them, and a typo here is rejected outright. Second, &lt;code&gt;rules:&lt;/code&gt; has to be a YAML &lt;em&gt;list&lt;/em&gt; of rule objects. Written as a bare mapping (&lt;code&gt;if:&lt;/code&gt; directly under &lt;code&gt;rules:&lt;/code&gt;), it is malformed; GitLab cannot read it as a rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fcommon-gitlab-ci-mistakes-diagram.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopscanopy.com%2Fblog%2Fcommon-gitlab-ci-mistakes-diagram.svg" alt="A short broken .gitlab-ci.yml snippet with red callout bubbles pointing at an undefined stage, a job with no script, and a bad needs reference" width="1200" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other half of this category is logic, and it is harder to spot because the YAML is valid. A rule whose &lt;code&gt;if:&lt;/code&gt; references a variable that is empty on the branch you care about silently never matches, and the job never runs. A rule with no condition always matches. And &lt;code&gt;rules:&lt;/code&gt; cannot be combined with the legacy &lt;code&gt;only:&lt;/code&gt;/&lt;code&gt;except:&lt;/code&gt; keywords in the same job — GitLab errors if you use both. &lt;code&gt;only&lt;/code&gt;/&lt;code&gt;except&lt;/code&gt; still work, but they are no longer actively developed, so new pipelines should standardize on &lt;code&gt;rules&lt;/code&gt;. Write &lt;code&gt;rules&lt;/code&gt; as a list, with each item carrying its condition and &lt;code&gt;when&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$CI_COMMIT_TAG'&lt;/span&gt;
      &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your bug is an environment variable that is empty when you expected a value, that is a different class of problem — the &lt;a href="https://opscanopy.com/env-example-checker/" rel="noopener noreferrer"&gt;Env Example Checker&lt;/a&gt; catches the &lt;code&gt;.env&lt;/code&gt; versus &lt;code&gt;.env.example&lt;/code&gt; drift that leaves a variable undefined in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. extends a template that does not exist, or a circular extends
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;.base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;golang:1.22&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make&lt;/span&gt;

&lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.bse&lt;/span&gt;        &lt;span class="c1"&gt;# typo — .bse, not .base&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make lint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;extends:&lt;/code&gt; is GitLab's DRY mechanism: a job pulls in the keys of another job or hidden template and overrides what it needs. The most common failure is exactly the one above — a typo or a rename, so &lt;code&gt;extends&lt;/code&gt; points at a template that is not in the file. GitLab cannot resolve &lt;code&gt;.bse&lt;/code&gt;, and the job config is invalid.&lt;/p&gt;

&lt;p&gt;The nastier variant is a circular &lt;code&gt;extends&lt;/code&gt; — &lt;code&gt;a&lt;/code&gt; extends &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt; extends &lt;code&gt;a&lt;/code&gt; — which has no base case to resolve and is rejected. Keep the chain pointing at a real, terminal template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;.base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;golang:1.22&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make&lt;/span&gt;

&lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.base&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make lint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;extends&lt;/code&gt; can also take a list of templates, and each name in that list has to resolve. A single bad entry breaks the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. YAML anchors and hidden (dot-prefixed) jobs gone wrong
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;.deploy_template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;deploy&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;

&lt;span class="na"&gt;deploy_prod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*deploy&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="c1"&gt;# inherits stage + script from the anchor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitLab supports both YAML anchors (&lt;code&gt;&amp;amp;name&lt;/code&gt; / &lt;code&gt;*name&lt;/code&gt; with the &lt;code&gt;&amp;lt;&amp;lt;:&lt;/code&gt; merge key) and its own &lt;code&gt;extends:&lt;/code&gt;. The two solve the same problem and people mix them, which is where the trouble starts. The pattern above is correct: a dot-prefixed key is a &lt;em&gt;hidden&lt;/em&gt; job — GitLab does not run it as a job, it only exists to be reused. Anchoring it with &lt;code&gt;&amp;amp;deploy&lt;/code&gt; and merging it into &lt;code&gt;deploy_prod&lt;/code&gt; with &lt;code&gt;&amp;lt;&amp;lt;: *deploy&lt;/code&gt; works.&lt;/p&gt;

&lt;p&gt;What goes wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the dot.&lt;/strong&gt; If your template is named &lt;code&gt;deploy_template:&lt;/code&gt; without the leading dot, GitLab treats it as a real job — and a real job with no script (just an anchor target) triggers the no-script error from mistake two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anchors don't cross files.&lt;/strong&gt; A YAML anchor is local to one document. If you &lt;code&gt;include:&lt;/code&gt; another file and try to reference an anchor defined there, it will not resolve. &lt;code&gt;extends:&lt;/code&gt; is the cross-file-safe choice; reach for it when reuse spans includes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A merge key can't be partially overridden the way you think.&lt;/strong&gt; &lt;code&gt;&amp;lt;&amp;lt;:&lt;/code&gt; does a shallow merge, so re-declaring a nested key replaces the whole sub-tree rather than merging into it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When in doubt, prefer &lt;code&gt;extends:&lt;/code&gt; for job reuse and keep anchors for small, local scalar/list fragments. And always give a reusable template the leading dot so GitLab knows not to run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;.deploy_template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;

&lt;span class="na"&gt;deploy_prod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.deploy_template&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. include that 404s or points at the wrong file/ref
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;platform/ci-templates'&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/templates/deploy.yml'&lt;/span&gt;   &lt;span class="c1"&gt;# path or ref may be wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;include:&lt;/code&gt; pulls configuration from another file — local, a remote URL, a template, or another project. When the path, the &lt;code&gt;ref&lt;/code&gt;, or the project is wrong, GitLab cannot fetch it and the whole pipeline fails to compile, often with a blunt &lt;code&gt;Project not found or access denied&lt;/code&gt; or a 404 on the file. The usual causes are a leading-slash path mistake (local &lt;code&gt;include&lt;/code&gt; paths are relative to the repo root and need the slash; a &lt;code&gt;file:&lt;/code&gt; from a project also wants the absolute repo path), a &lt;code&gt;ref&lt;/code&gt; that points at a branch or tag that no longer exists, or a renamed template file.&lt;/p&gt;

&lt;p&gt;Make the path absolute-from-root, pin a &lt;code&gt;ref&lt;/code&gt; that exists, and double-check the project path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;platform/ci-templates'&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2.3.0&lt;/span&gt;          &lt;span class="c1"&gt;# a tag that exists&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/templates/deploy.yml'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/.ci/test.yml'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat worth knowing: resolving &lt;code&gt;include:&lt;/code&gt; requires actually fetching the referenced files, which a purely client-side checker cannot do. A local linter validates the &lt;em&gt;structure&lt;/em&gt; of your &lt;code&gt;include&lt;/code&gt; block; for the final word on whether a remote file resolves, GitLab's own CI Lint (which fetches includes and project variables) is the backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch them all at once
&lt;/h2&gt;

&lt;p&gt;Six of these seven mistakes are structural — they live in how the jobs, stages, and references fit together, not in whether the YAML parses. That is exactly the gap a syntax-only linter misses: a &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; can be perfectly valid YAML and still be a pipeline GitLab refuses to start.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://opscanopy.com/gitlab-ci-validator/" rel="noopener noreferrer"&gt;GitLab CI Validator&lt;/a&gt; runs these checks in your browser. Paste a &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; and it parses the YAML, then flags the structural problems above — an undefined stage, a job with no &lt;code&gt;script&lt;/code&gt;/&lt;code&gt;run&lt;/code&gt;/&lt;code&gt;trigger&lt;/code&gt;/&lt;code&gt;extends&lt;/code&gt;, &lt;code&gt;needs&lt;/code&gt;/&lt;code&gt;dependencies&lt;/code&gt;/&lt;code&gt;extends&lt;/code&gt; references that point at jobs that do not exist, an invalid &lt;code&gt;when:&lt;/code&gt;, a non-list &lt;code&gt;rules:&lt;/code&gt;, legacy &lt;code&gt;only&lt;/code&gt;/&lt;code&gt;except&lt;/code&gt;, and bad &lt;code&gt;image&lt;/code&gt;/&lt;code&gt;services&lt;/code&gt; shapes — each with the line and a concrete fix. Nothing is uploaded; the whole check is client-side, so you can run it against private pipelines and proprietary runner config without sending anything anywhere.&lt;/p&gt;

&lt;p&gt;If your pipelines also run on GitHub, the same before-you-push idea applies to workflows — our walkthrough of &lt;a href="https://opscanopy.com/blog/github-actions-security-misconfigurations/" rel="noopener noreferrer"&gt;GitHub Actions security misconfigurations&lt;/a&gt; covers the GitHub-side equivalents, from over-broad token permissions to unpinned third-party actions.&lt;/p&gt;

&lt;p&gt;A red pipeline that never ran is the cheapest possible failure to prevent. Catch the structural mistakes before the commit, and the only red you see is a test that genuinely failed.&lt;/p&gt;

</description>
      <category>gitlabci</category>
      <category>cicd</category>
      <category>yaml</category>
    </item>
    <item>
      <title>Kubernetes isn't that scary — you really only need 8 objects (and to read 3 errors)</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 16:14:56 +0000</pubDate>
      <link>https://dev.to/opscanopy/kubernetes-isnt-that-scary-you-really-only-need-8-objects-and-to-read-3-errors-no6</link>
      <guid>https://dev.to/opscanopy/kubernetes-isnt-that-scary-you-really-only-need-8-objects-and-to-read-3-errors-no6</guid>
      <description>&lt;p&gt;The first time I opened a Kubernetes manifest, I closed my laptop. Pods, ReplicaSets, DaemonSets, StatefulSets, CRDs, operators, service meshes — it read like someone had thrown a dictionary into a YAML file and called it a platform. I was a backend dev who could ship a Docker container fine, and suddenly everyone on the team was talking about "the cluster" like it was a person.&lt;/p&gt;

&lt;p&gt;Here's what nobody told me back then, and what I want to tell you now: &lt;strong&gt;you do not need to learn all of Kubernetes to be productive in it.&lt;/strong&gt; You need maybe eight objects and the ability to read three error messages. That's the working set. Everything else you pick up the day you actually need it.&lt;/p&gt;

&lt;p&gt;So let me hand you the mental model I wish I'd had.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Learn Pod, Deployment, Service, Ingress, ConfigMap, Secret, Namespace, and resource requests/limits. Then learn to debug &lt;code&gt;CrashLoopBackOff&lt;/code&gt;, &lt;code&gt;ImagePullBackOff&lt;/code&gt;, and &lt;code&gt;OOMKilled&lt;/code&gt; with &lt;code&gt;kubectl describe&lt;/code&gt; and &lt;code&gt;kubectl logs&lt;/code&gt;. That's 90% of daily k8s.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One prerequisite, said plainly: you should be comfortable with containers first. If "image vs container" is fuzzy, spend an afternoon with the &lt;a href="https://opscanopy.com/learn/guides/docker-for-devops" rel="noopener noreferrer"&gt;Docker for DevOps guide&lt;/a&gt; before this. Kubernetes runs containers — it does not replace knowing what one is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: The 8 objects that actually matter
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pod — one running thing
&lt;/h3&gt;

&lt;p&gt;A Pod is the smallest unit Kubernetes will schedule. Think of it as a single seat on a bus: usually one container sits in it, occasionally a couple of containers that genuinely need to ride together (a main app plus a tiny sidecar). The bus is the node. K8s decides which seat on which bus your app gets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll rarely write a raw Pod like this in production, though. Which brings us to the object you actually use.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Deployment — the manager who keeps Pods alive
&lt;/h3&gt;

&lt;p&gt;If a Pod is one worker, a Deployment is the shift manager who guarantees "I always want three of these running, and when I hand you a new version, roll it out without dropping traffic." Kill a Pod by hand and the Deployment quietly spins up a replacement. That self-healing is the whole point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;selector&lt;/code&gt; and the &lt;code&gt;labels&lt;/code&gt; matching. That's not boilerplate you can skip — the Deployment finds its Pods by label. Mismatch them and you get zero running Pods and zero errors, which is a fun afternoon. (Ask me how I know.)&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Service — a stable address for a moving target
&lt;/h3&gt;

&lt;p&gt;Pods are cattle, not pets. They die, get rescheduled, change IPs constantly. So how does Pod A reliably talk to Pod B? A Service. It's a fixed phone number that always forwards to whichever Pods are currently healthy. You call the Service name; it load-balances behind the scenes.&lt;/p&gt;

&lt;p&gt;There are three flavours worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ClusterIP&lt;/strong&gt; (the default) — reachable only &lt;em&gt;inside&lt;/em&gt; the cluster. Most internal services live here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NodePort&lt;/strong&gt; — opens the same port on every node. Crude, but handy for quick testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LoadBalancer&lt;/strong&gt; — asks your cloud provider for a real external load balancer. This is how the outside world reaches you on a managed cluster.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;      &lt;span class="c1"&gt;# same label as the Deployment's Pods&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Ingress — the front-desk router for HTTP
&lt;/h3&gt;

&lt;p&gt;A LoadBalancer per service gets expensive and messy fast. Ingress fixes that: it's one HTTP(S) router sitting at the edge, mapping hostnames and paths to the right Service. &lt;code&gt;api.example.com&lt;/code&gt; goes here, &lt;code&gt;app.example.com/admin&lt;/code&gt; goes there, and TLS termination happens in one place. You need an ingress controller running (nginx-ingress, Traefik, or your cloud's) for the Ingress object to mean anything — the YAML is just the routing table; the controller is the actual receptionist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5 and 6. ConfigMap and Secret — pulling config out of your image
&lt;/h3&gt;

&lt;p&gt;You don't bake the database URL into your image. You inject it. ConfigMap holds non-sensitive config (feature flags, log levels, that database &lt;em&gt;host&lt;/em&gt;); Secret holds the sensitive stuff (passwords, API tokens).&lt;/p&gt;

&lt;p&gt;They look almost identical to use. But one honest warning, because this trips people up constantly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Footgun:&lt;/strong&gt; A Kubernetes Secret is only &lt;strong&gt;base64-encoded&lt;/strong&gt;, not encrypted. Anyone who can read the Secret can &lt;code&gt;base64 -d&lt;/code&gt; it in two seconds. By default it sits in etcd unencrypted too. To make Secrets actually secret, you need encryption-at-rest enabled on etcd plus tight RBAC. Treat "Secret" as "marked sensitive," not "safe."&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-config&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;info"&lt;/span&gt;
  &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres.internal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Secret is the same shape with &lt;code&gt;kind: Secret&lt;/code&gt; and base64 values under &lt;code&gt;data:&lt;/code&gt;. Use one for the password, the ConfigMap for everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Namespace — rooms in the same house
&lt;/h3&gt;

&lt;p&gt;A Namespace is a logical partition inside one cluster. &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;prod&lt;/code&gt;, or a namespace per team. Names only need to be unique &lt;em&gt;within&lt;/em&gt; a namespace, so two teams can both have a Service called &lt;code&gt;web&lt;/code&gt; without colliding. It's also where you hang quotas and access rules. Small concept, saves a lot of arguments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace staging
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8. Resource requests and limits — the most-skipped, most-important setting
&lt;/h3&gt;

&lt;p&gt;This is the one beginners ignore and then page themselves at 2am over. Every container can declare two numbers for CPU and memory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;request&lt;/strong&gt; — what the scheduler reserves for you. It's used to decide which node has room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;limit&lt;/strong&gt; — the hard ceiling. Go over the memory limit and the kernel kills your container (more on that in Part 2).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;250m"&lt;/span&gt;      &lt;span class="c1"&gt;# 0.25 of a CPU core&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512Mi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These numbers also decide your Pod's &lt;strong&gt;QoS class&lt;/strong&gt;, which determines who gets evicted first when a node runs out of memory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guaranteed&lt;/strong&gt; — requests equal limits for every resource. Last to be killed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burstable&lt;/strong&gt; — requests set, limits higher (or partial). The common middle ground.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BestEffort&lt;/strong&gt; — nothing set at all. First against the wall when memory gets tight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Guessing these numbers is genuinely hard, and "I'll set it later" usually means "never." If you want sane starting values without spreadsheet math, OpsCanopy has a &lt;a href="https://opscanopy.com/kubernetes-resource-calculator" rel="noopener noreferrer"&gt;free in-browser Kubernetes resource calculator&lt;/a&gt; — punch in what your app roughly needs and it gives you requests/limits you can paste straight into the manifest. No signup, runs entirely in your browser.&lt;/p&gt;

&lt;p&gt;While we're here: &lt;strong&gt;liveness and readiness probes&lt;/strong&gt; are worth a mention even though they're not on the core-8 list. A readiness probe tells the Service "don't send me traffic until I'm warmed up." A liveness probe tells the kubelet "if this check fails, restart me." Get them backwards and you'll restart healthy pods or route traffic into cold ones. Learn them right after you're comfortable with the eight above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: The 3 errors you WILL hit
&lt;/h2&gt;

&lt;p&gt;You will see these. Not "might." The good news is they're readable once you know where to look, and the where-to-look is almost always the same two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods                 &lt;span class="c"&gt;# spot the bad pod and its status&lt;/span&gt;
kubectl describe pod &amp;lt;name&amp;gt;      &lt;span class="c"&gt;# the Events section is gold&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CrashLoopBackOff — your container keeps dying on startup
&lt;/h3&gt;

&lt;p&gt;This means the container starts, exits, gets restarted, exits again, and Kubernetes is now backing off (waiting longer between each retry) so it doesn't hammer a doomed process. The status sounds dramatic; it just means "your app won't stay up."&lt;/p&gt;

&lt;p&gt;It's almost never Kubernetes' fault. It's your app crashing — a missing env var, a bad config, an unreachable database, a typo'd start command. So read your app's own logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &amp;lt;pod-name&amp;gt;
kubectl logs &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;--previous&lt;/span&gt;   &lt;span class="c"&gt;# logs from the crashed instance, before the restart&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;--previous&lt;/code&gt; flag is the trick most people miss. By the time you run &lt;code&gt;kubectl logs&lt;/code&gt;, the current container may be a fresh restart with nothing useful in it. &lt;code&gt;--previous&lt;/code&gt; shows you the instance that actually died — that's where your stack trace lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  ImagePullBackOff — Kubernetes can't get your image
&lt;/h3&gt;

&lt;p&gt;Different beast entirely. The container never even started, because the node couldn't pull the image. Same backoff idea (it's retrying with increasing delays), different cause. Usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A typo in the image name or tag (&lt;code&gt;ngnix:1.27&lt;/code&gt; instead of &lt;code&gt;nginx&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The tag doesn't exist in the registry&lt;/li&gt;
&lt;li&gt;It's a private registry and you didn't give the cluster pull credentials (&lt;code&gt;imagePullSecrets&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact reason is sitting in the events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt;
&lt;span class="c"&gt;# scroll to Events — you'll see the real message,&lt;/span&gt;
&lt;span class="c"&gt;# e.g. "Failed to pull image ... not found" or "unauthorized"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something cluster-wide feels off and a single pod's events aren't enough, widen the lens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get events &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.lastTimestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a chronological feed of what the cluster's been doing — often the clearest narrative of what broke and when.&lt;/p&gt;

&lt;h3&gt;
  
  
  OOMKilled — your container ate too much memory
&lt;/h3&gt;

&lt;p&gt;OOM = Out Of Memory. Your container tried to use more memory than its &lt;strong&gt;limit&lt;/strong&gt;, and the kernel killed it. (That's the OOM killer doing exactly what you told it to.) You'll spot it in the pod's state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt;
&lt;span class="c"&gt;# Look for: State: Terminated   Reason: OOMKilled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the part that connects back to Part 1: OOMKilled is the direct consequence of memory limits, so the fix is rarely "give it infinite memory." It's setting the &lt;em&gt;right&lt;/em&gt; limit. Too low and you get killed under normal load. Too high and the scheduler over-commits the node and everyone suffers. This is exactly why guessing requests/limits by hand is risky, and why I'll point you one more time at that &lt;a href="https://opscanopy.com/kubernetes-resource-calculator" rel="noopener noreferrer"&gt;resource calculator&lt;/a&gt; to get a defensible starting point instead of a vibe.&lt;/p&gt;

&lt;p&gt;Sometimes OOMKilled is a real memory leak in your app, and no limit will save you — but the limit is what turns "node falls over and takes ten other apps with it" into "one pod restarts cleanly." That blast-radius containment is the feature, not the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;That's the honest core of Kubernetes. Eight objects, three errors, two debugging commands you'll type a thousand times. You're not "done" — nobody is — but you can now read a manifest, deploy something, and figure out why it broke without spiralling. That's the threshold where k8s stops being scary and starts being useful.&lt;/p&gt;

&lt;p&gt;When you're ready to go deeper — probes done properly, rollouts and rollbacks, StatefulSets, RBAC, the bits I deliberately skipped — the &lt;a href="https://opscanopy.com/learn/guides/kubernetes-for-devops" rel="noopener noreferrer"&gt;Kubernetes for DevOps guide&lt;/a&gt; on OpsCanopy walks through all of it with working YAML, and the &lt;a href="https://opscanopy.com/learn/roadmaps/kubernetes" rel="noopener noreferrer"&gt;Kubernetes roadmap&lt;/a&gt; lays out a sane order to learn things so you're not jumping around. Both are free, no account needed, and they sit alongside the rest of the &lt;a href="https://opscanopy.com/learn" rel="noopener noreferrer"&gt;Learn hub&lt;/a&gt; if you're picking up DevOps more broadly.&lt;/p&gt;

&lt;p&gt;Open a manifest. Deploy something small. Break it on purpose and read the error. That loop is how it finally clicked for me, and it'll click for you faster than you think.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>beginners</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The 12 Linux commands I actually run every day as a DevOps engineer</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 16:13:44 +0000</pubDate>
      <link>https://dev.to/opscanopy/the-12-linux-commands-i-actually-run-every-day-as-a-devops-engineer-5e55</link>
      <guid>https://dev.to/opscanopy/the-12-linux-commands-i-actually-run-every-day-as-a-devops-engineer-5e55</guid>
      <description>&lt;p&gt;Every Linux tutorial throws a hundred commands at you. &lt;code&gt;tar&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;cut&lt;/code&gt;, &lt;code&gt;xargs&lt;/code&gt;, forty flags each, and you nod along and forget all of it by Tuesday.&lt;/p&gt;

&lt;p&gt;Then you get paged at 3am. Production's down — now what? And in that moment your brain doesn't reach for the hundred. It reaches for maybe twelve. The same twelve, every single incident. Those are the ones worth burning into muscle memory.&lt;/p&gt;

&lt;p&gt;This is that list. Not the comprehensive one — the &lt;em&gt;honest&lt;/em&gt; one. For each command I'll give you a real situation, a copy-pasteable example, and the flag explanations that actually matter. Plus a mistake or two I've made so you don't have to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Learn to answer four questions fast — &lt;em&gt;what's broken?&lt;/em&gt; (&lt;code&gt;ps&lt;/code&gt;, &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;), &lt;em&gt;what do the logs say?&lt;/em&gt; (&lt;code&gt;journalctl&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;tail&lt;/code&gt;), &lt;em&gt;who can touch this file?&lt;/em&gt; (&lt;code&gt;find&lt;/code&gt;, &lt;code&gt;chmod&lt;/code&gt;/&lt;code&gt;chown&lt;/code&gt;), and &lt;em&gt;can I reach that box?&lt;/em&gt; (&lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;systemctl&lt;/code&gt;). Everything else you can Google mid-incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find out what's wrong fast
&lt;/h2&gt;

&lt;p&gt;The first 90 seconds of an incident are about &lt;em&gt;triage&lt;/em&gt;, not fixing. You want a fast read on CPU, memory, disk, and ports before you touch anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;top&lt;/code&gt; (and &lt;code&gt;ps&lt;/code&gt; for the surgical version)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;top&lt;/code&gt; is your live dashboard. Open it, press &lt;code&gt;M&lt;/code&gt; to sort by memory or &lt;code&gt;P&lt;/code&gt; for CPU, and you'll usually spot the offender in five seconds.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;top&lt;/code&gt; is interactive and noisy. When I want a one-shot answer — &lt;em&gt;which process is eating all the RAM?&lt;/em&gt; — I reach for &lt;code&gt;ps&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps aux &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;-%mem | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aux&lt;/code&gt; shows every process with user and resource columns. &lt;code&gt;--sort=-%mem&lt;/code&gt; orders by memory descending (the &lt;code&gt;-&lt;/code&gt; means descending), and &lt;code&gt;head&lt;/code&gt; keeps the top 10 so your terminal doesn't scroll into oblivion. Swap in &lt;code&gt;-%cpu&lt;/code&gt; when CPU is the suspect.&lt;/p&gt;

&lt;p&gt;Pro tip: the &lt;code&gt;RSS&lt;/code&gt; column is real memory in KB. That's the number that gets a process OOM-killed, not the scary-looking &lt;code&gt;VSZ&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;df -h&lt;/code&gt; — is the disk full?
&lt;/h3&gt;

&lt;p&gt;You would not believe how many "the app is down" incidents are just a full disk. Logs filled the partition, writes started failing, everything fell over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-h&lt;/code&gt; means human-readable (GB/MB instead of raw blocks). Glance at the &lt;code&gt;Use%&lt;/code&gt; column. Anything at 100% is your problem. Check &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/var&lt;/code&gt; first — &lt;code&gt;/var/log&lt;/code&gt; is the usual culprit.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;du -sh * | sort -h&lt;/code&gt; — &lt;em&gt;where&lt;/em&gt; did the disk go?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;df&lt;/code&gt; tells you the partition's full. &lt;code&gt;du&lt;/code&gt; tells you which directory ate it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-s&lt;/code&gt; summarizes each entry (instead of listing every file inside), &lt;code&gt;-h&lt;/code&gt; is human-readable again, and &lt;code&gt;sort -h&lt;/code&gt; sorts those human sizes correctly so &lt;code&gt;2G&lt;/code&gt; lands above &lt;code&gt;500M&lt;/code&gt;. Run it, &lt;code&gt;cd&lt;/code&gt; into the biggest dir, run it again. Two or three hops and you've found the runaway log file.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;ss -tulpn&lt;/code&gt; — what's listening on which port?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;netstat&lt;/code&gt; is dead, long live &lt;code&gt;ss&lt;/code&gt;. When a service won't start because "address already in use," or you just want to know what's actually bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: &lt;code&gt;t&lt;/code&gt; TCP, &lt;code&gt;u&lt;/code&gt; UDP, &lt;code&gt;l&lt;/code&gt; listening sockets only, &lt;code&gt;p&lt;/code&gt; show the process, &lt;code&gt;n&lt;/code&gt; numeric (don't waste time resolving names). You'll see exactly which PID owns port 8080 before you go hunting.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once spent forty minutes convinced our API had a code bug because health checks were timing out. It wasn't the code. &lt;code&gt;df -h&lt;/code&gt; showed &lt;code&gt;/&lt;/code&gt; at 100% — a debug log left on &lt;code&gt;DEBUG&lt;/code&gt; over the weekend had quietly written 40GB. The fix was &lt;code&gt;truncate&lt;/code&gt; and a logrotate config. The lesson: check the boring stuff &lt;em&gt;first&lt;/em&gt;. The disk, the ports, the memory. The clever explanation is almost never the right one at 3am.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wrangle the logs
&lt;/h2&gt;

&lt;p&gt;This is where most of the time actually goes. Logs are the crime scene and these four tools are how you read it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;journalctl&lt;/code&gt; — the systemd logbook
&lt;/h3&gt;

&lt;p&gt;On any modern distro, this is where service logs live. The two flags I use constantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; nginx &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"10 min ago"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-u nginx&lt;/code&gt; filters to one unit. &lt;code&gt;--since "10 min ago"&lt;/code&gt; scopes the time window so you're not scrolling through yesterday. Add &lt;code&gt;-f&lt;/code&gt; to follow live, or &lt;code&gt;-e&lt;/code&gt; to jump to the end. When a service just died, &lt;code&gt;journalctl -u &amp;lt;service&amp;gt; -e&lt;/code&gt; is usually the first command I type.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. &lt;code&gt;grep&lt;/code&gt; — find the needle
&lt;/h3&gt;

&lt;p&gt;The one you'll run most. Searching a tree of logs for errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"ERROR"&lt;/span&gt; /var/log/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-r&lt;/code&gt; recurses into subdirectories, &lt;code&gt;-n&lt;/code&gt; prints line numbers so you can jump straight there. Add &lt;code&gt;-i&lt;/code&gt; to ignore case (because some apps log &lt;code&gt;Error&lt;/code&gt;, some &lt;code&gt;ERROR&lt;/code&gt;, some &lt;code&gt;error&lt;/code&gt; — consistency is a myth).&lt;/p&gt;

&lt;p&gt;The catch with grep is the &lt;em&gt;pattern&lt;/em&gt;. Simple strings are easy; real log-mining needs regex, and getting a regex right by trial and error inside a live &lt;code&gt;grep&lt;/code&gt; is miserable. I build and test patterns in a browser first using OpsCanopy's &lt;a href="https://opscanopy.com/regex-log-tester" rel="noopener noreferrer"&gt;regex log tester&lt;/a&gt; — paste a few sample log lines, tweak the pattern until it highlights exactly what you want, then drop it into grep. Saves a lot of "why is this matching nothing" frustration.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. &lt;code&gt;awk&lt;/code&gt; — when you need columns
&lt;/h3&gt;

&lt;p&gt;grep finds lines. &lt;code&gt;awk&lt;/code&gt; slices them into fields. The classic "who is hammering my server?" one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt; access.log | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{print $1}&lt;/code&gt; pulls the first column (the client IP in a standard access log). &lt;code&gt;sort&lt;/code&gt; groups identical IPs together, &lt;code&gt;uniq -c&lt;/code&gt; counts each group, and &lt;code&gt;sort -rn&lt;/code&gt; sorts those counts numerically, highest first. Top of the list is your top talker. (Could be a bot, could be a misconfigured client, could be a DDoS — but now you have a number.)&lt;/p&gt;

&lt;h3&gt;
  
  
  8. &lt;code&gt;tail -f&lt;/code&gt; — watch it happen live
&lt;/h3&gt;

&lt;p&gt;Reproducing a bug and want to see the log react in real time?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/syslog | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nb"&gt;timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-f&lt;/code&gt; follows the file as new lines append. Piping into &lt;code&gt;grep -i timeout&lt;/code&gt; means you only see the lines you care about, case-insensitively, as they arrive. Trigger the action in another window and watch. There's something deeply satisfying about seeing the exact line appear the instant you click the button.&lt;/p&gt;

&lt;p&gt;If you want the regex side of grep/awk to stop being guesswork, the &lt;a href="https://opscanopy.com/learn/guides/linux-for-devops" rel="noopener noreferrer"&gt;Linux for DevOps guide&lt;/a&gt; walks through log-wrangling patterns properly, not just "here's the flag."&lt;/p&gt;

&lt;h2&gt;
  
  
  Files &amp;amp; permissions
&lt;/h2&gt;

&lt;p&gt;Half of "permission denied" tickets are one &lt;code&gt;chmod&lt;/code&gt; away from solved. The other half are someone who already ran the wrong &lt;code&gt;chmod&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. &lt;code&gt;find&lt;/code&gt; — locate by criteria, not just name
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;find&lt;/code&gt; is a search engine for your filesystem. Tracking down logs modified in the last day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find / &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.log"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-name "*.log"&lt;/code&gt; matches by filename pattern, &lt;code&gt;-mtime -1&lt;/code&gt; means modified less than 1 day ago (&lt;code&gt;-mtime +7&lt;/code&gt; would be older than a week — handy for cleanup). Start from a specific directory instead of &lt;code&gt;/&lt;/code&gt; when you can; searching the whole root is slow and touches everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. &lt;code&gt;chmod&lt;/code&gt; / &lt;code&gt;chown&lt;/code&gt; — fix who can do what
&lt;/h3&gt;

&lt;p&gt;A private key with loose permissions? SSH will flat-out refuse it. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/id_rsa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;600&lt;/code&gt; means owner read+write, nobody else gets anything. That's the canonical permission for secrets and keys. For ownership, &lt;code&gt;chown user:group file&lt;/code&gt; reassigns who owns it — common after copying files around as root.&lt;/p&gt;

&lt;p&gt;Confession time: early on I ran &lt;code&gt;chmod -R 777&lt;/code&gt; on a directory to "fix a permissions issue." Don't. &lt;code&gt;777&lt;/code&gt; means &lt;em&gt;everyone can read, write, and execute everything&lt;/em&gt;, recursively. It doesn't fix permissions, it deletes the concept of permissions. I spent the next hour undoing it and explaining myself. Use the &lt;em&gt;least&lt;/em&gt; access that works, never the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reach other machines
&lt;/h2&gt;

&lt;p&gt;Your stuff runs on boxes that aren't yours. These three get you there and tell you if they're alive.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. &lt;code&gt;ssh&lt;/code&gt; — get onto the box
&lt;/h3&gt;

&lt;p&gt;The front door to remote work. Basic form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy@10.0.4.12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll live in this command. The real upgrade is your &lt;code&gt;~/.ssh/config&lt;/code&gt; — define a &lt;code&gt;Host&lt;/code&gt; alias once and &lt;code&gt;ssh prod-web&lt;/code&gt; beats typing the full user-and-IP every time. Set up key auth and stop typing passwords entirely. (And see command 10 — if your key permissions are wrong, ssh will refuse before it even tries.)&lt;/p&gt;

&lt;h3&gt;
  
  
  12. &lt;code&gt;curl&lt;/code&gt; — is the endpoint actually up?
&lt;/h3&gt;

&lt;p&gt;My favorite health-check one-liner. Skip the response body, just tell me the HTTP status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.example.com/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking it down: &lt;code&gt;-s&lt;/code&gt; silences the progress bar, &lt;code&gt;-S&lt;/code&gt; keeps real errors visible (so a silent failure still shouts), &lt;code&gt;-o /dev/null&lt;/code&gt; throws away the body, and &lt;code&gt;-w "%{http_code}\n"&lt;/code&gt; prints just the status code with a newline. You get back a clean &lt;code&gt;200&lt;/code&gt; (or a &lt;code&gt;503&lt;/code&gt; that tells you exactly what's wrong). Drop it in a loop and you've got a poor man's uptime monitor.&lt;/p&gt;

&lt;p&gt;For the deeper "why can't this box reach that box" rabbit holes — DNS, routing, ports, TLS — the &lt;a href="https://opscanopy.com/learn/guides/networking-for-devops" rel="noopener noreferrer"&gt;networking guide&lt;/a&gt; pairs naturally with &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt;, and &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest takeaway
&lt;/h2&gt;

&lt;p&gt;Nobody memorizes a hundred commands. You memorize the dozen that answer your real questions, and you get fast at &lt;em&gt;those&lt;/em&gt;. Everything else is &lt;code&gt;man&lt;/code&gt; pages and search history.&lt;/p&gt;

&lt;p&gt;If you're moving into DevOps and want to go from "I can copy-paste these" to "I actually understand what's happening underneath," start here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📘 The full &lt;a href="https://opscanopy.com/learn/guides/linux-for-devops" rel="noopener noreferrer"&gt;Linux for DevOps guide&lt;/a&gt; — these commands in real context, with the why.&lt;/li&gt;
&lt;li&gt;🗺️ The &lt;a href="https://opscanopy.com/learn/roadmaps/linux" rel="noopener noreferrer"&gt;Linux roadmap&lt;/a&gt; — a step-by-step path if you don't know what to learn next.&lt;/li&gt;
&lt;li&gt;🧰 Or browse everything in the &lt;a href="https://opscanopy.com/learn" rel="noopener noreferrer"&gt;Learn hub&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bookmark this, drill the twelve, and the next 3am page will feel a little less like a fire and a little more like a checklist. That's the whole job, honestly.&lt;/p&gt;

&lt;p&gt;What's the one command you reach for that I left off the list? I'm always looking to steal a good one.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>14 Docker interview questions I actually got asked in 2026 (and how I'd answer them)</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Mon, 27 Jul 2026 16:13:40 +0000</pubDate>
      <link>https://dev.to/opscanopy/14-docker-interview-questions-i-actually-got-asked-in-2026-and-how-id-answer-them-239a</link>
      <guid>https://dev.to/opscanopy/14-docker-interview-questions-i-actually-got-asked-in-2026-and-how-id-answer-them-239a</guid>
      <description>&lt;p&gt;I've been on both sides of the table. Some years I'm the one sweating through a Docker round; other years I'm the one asking "okay, your image is 1.2GB, walk me through shrinking it." So this list isn't scraped off the internet. These are questions I either got asked in 2026 or asked candidates myself, mostly for backend/DevOps roles (a chunk of these come up in almost every product-company interview loop).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Interviewers don't want textbook definitions. They want to know you've actually run &lt;code&gt;docker logs&lt;/code&gt; on a crashing container at 11pm. Below are 14 questions grouped into Fundamentals, Images &amp;amp; Builds, Networking &amp;amp; Storage, and Debugging. Each has the question, what they're &lt;em&gt;really&lt;/em&gt; testing, and the answer I'd give.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fundamentals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. What's the difference between a container and a VM?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What they're really testing:&lt;/em&gt; whether you understand that containers share the host kernel.&lt;/p&gt;

&lt;p&gt;A VM virtualizes hardware and runs a full guest OS with its own kernel, so it's heavy (gigabytes, slow boot). A container is just an isolated process on the host — it shares the host kernel and only packages the app plus its userland dependencies. That's why containers start in milliseconds and you can pack dozens on one box. The trade-off: weaker isolation than a VM, since everyone shares that kernel.&lt;/p&gt;

&lt;p&gt;Honestly, most candidates nail this one because it's the first thing every tutorial covers. The follow-up is where people slip: "so can you run a Windows container on a Linux host?" (No — the kernel has to match.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What actually happens when you run &lt;code&gt;docker run&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Docker pulls the image if it's not local, creates a writable container layer on top of the read-only image layers, sets up networking and namespaces/cgroups, then starts the process defined by ENTRYPOINT/CMD as PID 1 inside the container. When that process exits, the container stops. That last sentence matters more than it sounds — half the "my container won't stay up" problems trace back to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Image vs container — explain it like I'm new.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An image is the read-only template (a stack of layers). A container is a running — or stopped — instance of that image with a thin writable layer on top. One image, many containers. The class-vs-object analogy works fine here and interviewers don't mind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. What's the deal with image layers and the build cache?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each instruction in a Dockerfile (&lt;code&gt;RUN&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;ADD&lt;/code&gt;) creates a layer. Layers are cached and content-addressed, so if nothing above a line changed, Docker reuses the cached layer instead of rebuilding it. This is why instruction &lt;em&gt;order&lt;/em&gt; is a real skill, not pedantry. Put the stuff that rarely changes first, and the stuff that changes every commit (your source code) last:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci          &lt;span class="c"&gt;# cached unless deps change&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .            # only this busts on a code change&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you &lt;code&gt;COPY . .&lt;/code&gt; before installing deps, every one-line code change reinstalls your whole dependency tree. I've seen 9-minute CI builds drop to 40 seconds just from reordering these two blocks. (For a fuller walkthrough of the layer model, the &lt;a href="https://opscanopy.com/learn/guides/docker-for-devops" rel="noopener noreferrer"&gt;Docker fundamentals guide&lt;/a&gt; goes deeper than I can here.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Images &amp;amp; Builds
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;5. Your production image is 1.2GB. How do you shrink it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What they're really testing:&lt;/em&gt; practical instincts, not trivia. This is my favorite question to ask.&lt;/p&gt;

&lt;p&gt;Things I'd actually do, roughly in order of impact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stage build.&lt;/strong&gt; Compile/install in a fat builder stage, copy only the artifact into a clean runtime stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller base image.&lt;/strong&gt; Swap &lt;code&gt;node:20&lt;/code&gt; for &lt;code&gt;node:20-alpine&lt;/code&gt;, or go distroless for compiled languages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.dockerignore&lt;/code&gt;.&lt;/strong&gt; Stop shipping &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, and local junk into the build context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collapse layers&lt;/strong&gt; and clean package caches in the same &lt;code&gt;RUN&lt;/code&gt; (&lt;code&gt;rm -rf /var/lib/apt/lists/*&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The multi-stage one usually does most of the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.23&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go build &lt;span class="nt"&gt;-o&lt;/span&gt; /app ./cmd/server

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/static&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app /app&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/app"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That Go binary plus distroless can land under 20MB. The answer that impresses isn't reciting all four bullets — it's saying "first I'd run &lt;code&gt;docker history&lt;/code&gt; to see which layer is fat, &lt;em&gt;then&lt;/em&gt; decide."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Multi-stage builds — why bother?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because your build tools aren't your runtime needs. You need a compiler, headers, and a package manager to &lt;em&gt;build&lt;/em&gt;; you need none of that to &lt;em&gt;run&lt;/em&gt;. Multi-stage lets you keep build dependencies in an early stage and copy just the output forward with &lt;code&gt;COPY --from=build&lt;/code&gt;, so the final image is small and has a smaller attack surface. Bonus: no more separate "build image" and "run image" shell scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. ENTRYPOINT vs CMD?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ENTRYPOINT sets the executable that always runs; CMD sets default arguments (or a default command) that are easy to override at &lt;code&gt;docker run&lt;/code&gt;. Common pattern: &lt;code&gt;ENTRYPOINT ["python"]&lt;/code&gt; plus &lt;code&gt;CMD ["app.py"]&lt;/code&gt;, so &lt;code&gt;docker run img test.py&lt;/code&gt; swaps the script but keeps the interpreter. Also — use the exec form (&lt;code&gt;["..."]&lt;/code&gt;, JSON array), not the shell form, or your process won't get signals correctly and won't run as PID 1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Quick tip: if &lt;code&gt;docker stop&lt;/code&gt; takes a full 10 seconds and then kills your container hard, it's almost always the shell-form ENTRYPOINT swallowing SIGTERM. Switch to exec form.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;8. COPY vs ADD?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use COPY. It just copies files from the build context into the image, no surprises. ADD does that &lt;em&gt;plus&lt;/em&gt; two magic things: it auto-extracts local tar archives and can fetch remote URLs. Both behaviors bite people, so the convention is COPY by default, ADD only when you specifically want tar extraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. What goes in a &lt;code&gt;.dockerignore&lt;/code&gt; and why does it matter?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anything you don't want sent to the Docker daemon as build context: &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt;, logs, test fixtures. Two reasons it matters — a bloated context slows every build (the whole thing gets tarred and shipped to the daemon), and &lt;code&gt;COPY . .&lt;/code&gt; can accidentally bake secrets or local config into the image. It's basically &lt;code&gt;.gitignore&lt;/code&gt; for builds, and forgetting it is a classic junior mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking &amp;amp; Storage
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;10. Walk me through Docker's default network modes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three you should know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;bridge&lt;/strong&gt; (default): containers get a private IP on a virtual bridge and talk to each other by name on a user-defined network. Outside access needs published ports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;host&lt;/strong&gt;: the container shares the host's network stack directly — no isolation, no port mapping, slightly faster. Linux only, really.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;none&lt;/strong&gt;: no networking at all. Useful for batch jobs that shouldn't touch the network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detail that earns points: on the &lt;em&gt;default&lt;/em&gt; bridge, containers can't resolve each other by name — you only get automatic DNS on a &lt;strong&gt;user-defined&lt;/strong&gt; bridge network. So in real projects you create one: &lt;code&gt;docker network create appnet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. What does &lt;code&gt;-p 8080:80&lt;/code&gt; actually do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It publishes a port: maps port 8080 on the host to port 80 inside the container. Left side is host, right side is container — people flip these under pressure constantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;localhost:8080&lt;/code&gt; hits nginx listening on 80 inside the container. Without &lt;code&gt;-p&lt;/code&gt;, the container's port is reachable from other containers on the same network but not from your laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Bind mount vs named volume — when do you use which?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A bind mount maps a specific host path into the container (&lt;code&gt;-v $(pwd):/app&lt;/code&gt;) — great for local dev where you want live code reloads, but it's tied to the host's filesystem layout. A named volume is managed by Docker (&lt;code&gt;-v appdata:/var/lib/postgresql/data&lt;/code&gt;), lives in Docker's storage area, and is the right call for persistent data like databases in production. Rule of thumb I give people: bind mounts for dev convenience, named volumes for data you can't lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging &amp;amp; Scenarios
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;13. A container exits immediately after starting. How do you debug it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What they're really testing:&lt;/em&gt; do you reach for logs first, or start guessing.&lt;/p&gt;

&lt;p&gt;My order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;docker ps -a&lt;/code&gt; — check the exit code. 0 means it finished on purpose; non-zero means it crashed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker logs &amp;lt;container&amp;gt;&lt;/code&gt; — 90% of the time the error is right there.&lt;/li&gt;
&lt;li&gt;If it's a "main process exited so the container stopped" situation, ask whether the command is actually a long-running foreground process. A container with &lt;code&gt;CMD ["npm", "run", "build"]&lt;/code&gt; will &lt;em&gt;always&lt;/em&gt; exit — build finishes, PID 1 dies, container stops. That's not a bug.&lt;/li&gt;
&lt;li&gt;Still stuck? Override the entrypoint and poke around:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; sh myimage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The candidates who impress me say "logs first." The ones who worry me start by rebuilding the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. &lt;code&gt;docker exec&lt;/code&gt; vs &lt;code&gt;docker run&lt;/code&gt; — and how do you get a shell into a &lt;em&gt;running&lt;/em&gt; container?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker run&lt;/code&gt; creates and starts a &lt;strong&gt;new&lt;/strong&gt; container from an image. &lt;code&gt;docker exec&lt;/code&gt; runs a command in an &lt;strong&gt;already-running&lt;/strong&gt; container. So to debug live, you exec in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; web sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;sh&lt;/code&gt; isn't there (distroless, scratch), that's the price of a tiny image — you debug from outside, or temporarily run a debug-friendly variant. Mixing these two up is one of the most common slips, and clearing it up cleanly signals real hands-on time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus — restart policies and healthchecks (they always sneak this in):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Restart policies tell Docker what to do when a container dies: &lt;code&gt;--restart=on-failure&lt;/code&gt; retries only on non-zero exit, &lt;code&gt;--restart=unless-stopped&lt;/code&gt; keeps it alive across daemon restarts unless you explicitly stopped it. A HEALTHCHECK lets Docker probe whether your app is actually serving, not just whether the process exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=3s \&lt;/span&gt;
  CMD curl -f http://localhost/health || exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Knowing the difference between "process is up" and "app is healthy" is the kind of thing that separates someone who's run prod from someone who's only done tutorials.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually prep
&lt;/h2&gt;

&lt;p&gt;A pattern across every loop I've sat in: interviewers chain questions. They'll start at "container vs VM," and within four follow-ups you're explaining why your container exits with code 137 (that's OOM-kill, by the way — worth memorizing). So don't memorize answers; build a mental model you can reason from.&lt;/p&gt;

&lt;p&gt;If you want the structured version of this, OpsCanopy has a &lt;a href="https://opscanopy.com/learn/guides/docker-interview-questions" rel="noopener noreferrer"&gt;full Docker interview-questions guide&lt;/a&gt; with deeper answers and more scenario chains than I can fit in a dev.to post. Pair it with the &lt;a href="https://opscanopy.com/learn/roadmaps/docker" rel="noopener noreferrer"&gt;Docker roadmap&lt;/a&gt; if you're figuring out what to learn in what order, and once Docker clicks, &lt;a href="https://opscanopy.com/learn/guides/kubernetes-for-devops" rel="noopener noreferrer"&gt;Kubernetes for DevOps&lt;/a&gt; is the natural next step — most of these loops have a K8s round right after. Everything's free over at the &lt;a href="https://opscanopy.com/learn" rel="noopener noreferrer"&gt;Learn hub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I've got the full set with deeper answers and the nastier follow-ups &lt;a href="https://opscanopy.com/learn/guides/docker-interview-questions" rel="noopener noreferrer"&gt;right here&lt;/a&gt;. Go break some containers before your next interview — it's the fastest way to actually remember any of this.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>interview</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The Complete 90-Day DevOps Roadmap I Wish Existed When I Started (Free, No Signup)</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Tue, 14 Jul 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/opscanopy/the-complete-90-day-devops-roadmap-i-wish-existed-when-i-started-free-no-signup-2eki</link>
      <guid>https://dev.to/opscanopy/the-complete-90-day-devops-roadmap-i-wish-existed-when-i-started-free-no-signup-2eki</guid>
      <description>&lt;p&gt;I once watched a developer breeze through three Docker courses and then freeze solid the first time a real container crashed. Tutorials teach commands. Incidents teach engineers.&lt;/p&gt;

&lt;p&gt;Every DevOps roadmap I found when I started was either a 200-node skill tree with no order, or a funnel into a $400 course. So I spent the last few months building the thing I actually needed — a day-by-day, 90-day path from "I write application code" to "I can deploy, debug, and un-break infrastructure" — and the entire plan is in this post. You don't need to click a single link to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this order, and why 90 days
&lt;/h2&gt;

&lt;p&gt;Most roadmaps fail for one of two reasons: they present everything at once (so you bounce between Kubernetes videos and bash tutorials with no thread), or they teach tools in the wrong order (Kubernetes before Linux is how you end up copy-pasting &lt;code&gt;kubectl&lt;/code&gt; commands you can't debug).&lt;/p&gt;

&lt;p&gt;The ordering rule I settled on: &lt;strong&gt;every layer must let you debug the layer above it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes problems are usually container problems.&lt;/li&gt;
&lt;li&gt;Container problems are usually Linux problems.&lt;/li&gt;
&lt;li&gt;Cloud networking problems are usually DNS-and-ports problems you can learn on localhost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: Linux first, containers second, cloud third, orchestration fourth, job prep last. 45–60 minutes a day, about 80 hours of core work total. That's a real commitment — I won't pretend it's "10 minutes a day" — but it's doable alongside a full-time job, and 90 days is short enough that you can see the end from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1 — Linux &amp;amp; the terminal (Days 1–21)
&lt;/h2&gt;

&lt;p&gt;Three weeks feels like a lot for Linux when Kubernetes is the shiny thing. It isn't. Roughly every DevOps incident ends with someone SSH'd into a box reading logs, and this phase is where you stop being scared of that.&lt;/p&gt;

&lt;p&gt;What you cover: navigation, files, permissions, processes, systemd, logs, networking basics (DNS, ports, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;), bash scripting, cron, SSH, users.&lt;/p&gt;

&lt;p&gt;The test of whether Phase 1 worked isn't "can you list flags for &lt;code&gt;ls&lt;/code&gt;". It's whether this sequence feels natural:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;systemctl status nginx
● nginx.service - A high performance web server
     Active: failed &lt;span class="o"&gt;(&lt;/span&gt;Result: exit-code&lt;span class="o"&gt;)&lt;/span&gt; since Tue 09:41:02 UTC

&lt;span class="nv"&gt;$ &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; nginx &lt;span class="nt"&gt;-n&lt;/span&gt; 20 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
nginx[1247]: &lt;span class="nb"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; to 0.0.0.0:80 failed &lt;span class="o"&gt;(&lt;/span&gt;98: Address already &lt;span class="k"&gt;in &lt;/span&gt;use&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-ltnp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; :80
LISTEN 0 511 0.0.0.0:80 &lt;span class="nb"&gt;users&lt;/span&gt;:&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;"apache2"&lt;/span&gt;,pid&lt;span class="o"&gt;=&lt;/span&gt;1201,fd&lt;span class="o"&gt;=&lt;/span&gt;4&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the status, read the log, find the actual error string, find the process squatting on the port. That loop — &lt;em&gt;observe, read the real error, trace it down one layer&lt;/em&gt; — is the whole job. Everything after Phase 1 is that loop with more expensive tools.&lt;/p&gt;

&lt;p&gt;Everything here runs locally on WSL2 or any Linux box. Cost: $0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2 — Docker &amp;amp; CI/CD with GitHub Actions (Days 22–45)
&lt;/h2&gt;

&lt;p&gt;Two skills, deliberately paired: packaging software (Docker) and shipping it automatically (GitHub Actions). Learning one without the other leaves you either building images by hand forever, or writing pipelines you can't debug.&lt;/p&gt;

&lt;p&gt;Docker side: images vs containers, Dockerfiles, layers and caching, volumes, networks, compose. CI side: real Git workflows, then pipelines that lint, test, build, and push an image on every commit.&lt;/p&gt;

&lt;p&gt;This phase ends with &lt;strong&gt;Project 1&lt;/strong&gt;: containerize a real app — a FastAPI URL shortener called &lt;code&gt;linkstash&lt;/code&gt; — with Docker and compose, database included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;+] Building 42.3s &lt;span class="o"&gt;(&lt;/span&gt;11/11&lt;span class="o"&gt;)&lt;/span&gt; FINISHED
 ✔ Container linkstash-db-1   Healthy
 ✔ Container linkstash-api-1  Started

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8000/healthz
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;healthz&lt;/code&gt; line matters more than it looks. From here on, you carry this exact app up the rest of the stack — which I'll come back to.&lt;/p&gt;

&lt;p&gt;Still 100% local. Still $0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3 — AWS (Days 46–65)
&lt;/h2&gt;

&lt;p&gt;IAM, EC2, VPC networking, S3, RDS, load balancers, observability, and then real deployment. The order inside the phase matters too: IAM and VPC first, because 80% of AWS confusion is "why can't this thing talk to that thing", and the answer is almost always a security group or a subnet route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project 2&lt;/strong&gt;: take the same &lt;code&gt;linkstash&lt;/code&gt; container from Phase 2 and deploy it properly — ECS Fargate behind an ALB, RDS Postgres, spread across two availability zones. Not a toy deploy; the architecture a small company would actually run.&lt;/p&gt;

&lt;p&gt;Honest tradeoff: this is the phase that can cost money. The approach I take: every AWS lab carries a cost box up front and a &lt;strong&gt;mandatory teardown&lt;/strong&gt; at the end. Follow the teardowns and you stay in free tier. Skip them and AWS will remind you why one of the week-7 incident drills is literally called "AWS Bill Shock".&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4 — Kubernetes &amp;amp; Terraform (Days 66–85)
&lt;/h2&gt;

&lt;p&gt;Pods, deployments, services, config, probes, ingress, Helm — practiced on local clusters first (kind), so mistakes are free. Then Terraform, so you stop clicking around consoles and start writing infrastructure that's reviewable and repeatable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project 3&lt;/strong&gt; is the capstone: &lt;code&gt;linkstash&lt;/code&gt; again, now on Kubernetes — k3s on a single EC2 instance, provisioned entirely by Terraform, packaged as a Helm chart, with Traefik ingress and cert-manager doing real TLS.&lt;/p&gt;

&lt;p&gt;Why k3s on one t3.small instead of EKS? Money. EKS charges ~$73/month for the control plane before you run a single pod. The k3s setup is ~$19/month if you leave it up — effectively $0 on free tier with same-day teardown. You learn the same Kubernetes; you just don't pay AWS to hold the API server. The tradeoff: you won't touch EKS-specific glue (IRSA, managed node groups) — that's fine to learn on the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5 — Job-Ready (Days 86–90)
&lt;/h2&gt;

&lt;p&gt;The last five days are the part every roadmap skips: converting skills into a hire. Resume rewritten around the three projects (deployments and architecture decisions, not "familiar with Docker"), portfolio and GitHub polish, incident management fundamentals (severity levels, blameless postmortems, error budgets), and interview drilling on the exact Q&amp;amp;A you've been collecting — every single day of the program ends with 3–5 interview questions, so by day 86 you've already answered 300+.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day-by-day skeleton
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Days&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;Incident drill at the end&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1–7&lt;/td&gt;
&lt;td&gt;Terminal fluency: files, permissions, pipes&lt;/td&gt;
&lt;td&gt;Server Down!&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8–14&lt;/td&gt;
&lt;td&gt;Processes, systemd, logs, DNS &amp;amp; ports&lt;/td&gt;
&lt;td&gt;DNS Detective&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15–21&lt;/td&gt;
&lt;td&gt;Bash, cron, SSH, users&lt;/td&gt;
&lt;td&gt;Locked File&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22–28&lt;/td&gt;
&lt;td&gt;Docker: images, containers, Dockerfiles&lt;/td&gt;
&lt;td&gt;Docker Rescue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;29–35&lt;/td&gt;
&lt;td&gt;Compose, volumes, networks, registries&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;36–40&lt;/td&gt;
&lt;td&gt;Git workflows → GitHub Actions CI&lt;/td&gt;
&lt;td&gt;Broken Pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;41–45&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Project 1: containerize linkstash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;46–49&lt;/td&gt;
&lt;td&gt;AWS: IAM, EC2, cost hygiene&lt;/td&gt;
&lt;td&gt;AWS Bill Shock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50–56&lt;/td&gt;
&lt;td&gt;VPC, S3, RDS, backups&lt;/td&gt;
&lt;td&gt;Database Recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;57–61&lt;/td&gt;
&lt;td&gt;Load balancers, Route 53, monitoring&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;62–65&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Project 2: linkstash on AWS (Fargate + ALB + RDS)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;66–73&lt;/td&gt;
&lt;td&gt;Kubernetes: pods, services, config, probes, debugging&lt;/td&gt;
&lt;td&gt;Kubernetes Chaos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;74–80&lt;/td&gt;
&lt;td&gt;Ingress, Helm, Terraform&lt;/td&gt;
&lt;td&gt;Terraform Trouble&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;81–85&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Project 3: linkstash on k3s, all Terraform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;86–90&lt;/td&gt;
&lt;td&gt;Resume, portfolio, incidents, interview drill&lt;/td&gt;
&lt;td&gt;The Midnight Outage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Print that table. That's the roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  One app, carried up the whole stack
&lt;/h2&gt;

&lt;p&gt;The best design decision in the whole plan — and you can steal it even if you ignore everything else: &lt;strong&gt;don't build three throwaway projects. Build one app and carry it up the stack.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;linkstash&lt;/code&gt; gets containerized in Phase 2, deployed to AWS in Phase 3, and orchestrated on Kubernetes in Phase 4. In interviews, that turns "I did some tutorials" into "here's one system I've run three ways, and here's why each layer exists." The compare-and-contrast is the story interviewers actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody puts in roadmaps: incidents
&lt;/h2&gt;

&lt;p&gt;Reading about &lt;code&gt;journalctl&lt;/code&gt; and using it at 00:14 during an outage are different skills. Nobody gets paged because everything is working — ops is a job you do almost entirely on the failure path.&lt;/p&gt;

&lt;p&gt;So the plan ends every week with an &lt;strong&gt;incident drill&lt;/strong&gt; (that right-hand column in the table): a downed server, a DNS mystery, a broken pipeline, a surprise AWS bill, crashing pods. The final boss is "The Midnight Outage" — a SEV-1 where one security-group change cascades into DNS failover and pods that can't start, and you have to fix it &lt;strong&gt;upstream-first&lt;/strong&gt;: &lt;code&gt;aws&lt;/code&gt; → &lt;code&gt;kubectl&lt;/code&gt; → &lt;code&gt;dig&lt;/code&gt;, instead of restarting pods that were never the problem.&lt;/p&gt;

&lt;p&gt;Whether or not you follow my plan, steal the diagnostic walk order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Question to ask&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS / edge&lt;/td&gt;
&lt;td&gt;Where is traffic actually going?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dig +short &amp;lt;domain&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load balancer&lt;/td&gt;
&lt;td&gt;Are targets healthy?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws elbv2 describe-target-health&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;Did rules change recently?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws ec2 describe-security-groups&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestrator&lt;/td&gt;
&lt;td&gt;What state are the workloads in?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl get pods&lt;/code&gt; / &lt;code&gt;describe&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;What does the process itself say?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl logs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And to practice it today, break something yourself: &lt;code&gt;chmod 000&lt;/code&gt; a config your service reads, delete the Secret a deployment references, revoke a rule on a test security group, point a DNS record at nothing. Timer on, docs closed, fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest tradeoffs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;90 days makes you hireable-junior, not senior.&lt;/strong&gt; You'll be dangerous enough to contribute and debug; depth comes on the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-paced means no accountability partner.&lt;/strong&gt; A visible streak helps; it doesn't replace discipline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's opinionated.&lt;/strong&gt; GitHub Actions, not Jenkins. AWS, not Azure. k3s, not EKS. I chose the highest-leverage default in each slot; your target job may differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AWS phase requires a card on file.&lt;/strong&gt; Teardowns keep it near zero, but the risk isn't zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you want it guided
&lt;/h2&gt;

&lt;p&gt;Everything above is the complete plan — you can run it solo with the table and a search engine. But I also built it as a full free site: &lt;a href="https://opscanopy.com/mission-90/" rel="noopener noreferrer"&gt;Mission: 90 Days DevOps&lt;/a&gt;. Every one of the 90 days has the same shape — a short concept with a diagram, a hands-on lab with real commands and real outputs, a "Common Errors &amp;amp; Fixes" section with the actual error strings you'll hit, and 3–5 interview Q&amp;amp;A. The ten incident drills are playable &lt;strong&gt;missions in a browser terminal&lt;/strong&gt; — no setup, no code required.&lt;/p&gt;

&lt;p&gt;No signup, no email wall, no paywall. It's a static site; your progress saves in your browser. I'm one engineer building this in public, and the deal is simple: it's free, and if it helps, tell someone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with &lt;a href="https://opscanopy.com/mission-90/" rel="noopener noreferrer"&gt;Day 1&lt;/a&gt;, or just play the first mission — &lt;a href="https://opscanopy.com/mission-90/missions/" rel="noopener noreferrer"&gt;"Server Down!"&lt;/a&gt; takes about ten minutes and needs zero setup.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Two questions for the comments — I read every one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If you've already made the dev → DevOps jump:&lt;/strong&gt; what did your first real production incident teach you that no tutorial did? I'm collecting these — the best ones tend to become new missions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're starting now:&lt;/strong&gt; which phase looks scariest?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>beginners</category>
      <category>career</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>6 years of frontend, then I jumped to DevOps. The roadmap I'd actually follow in 2026</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 27 Jun 2026 18:54:59 +0000</pubDate>
      <link>https://dev.to/opscanopy/6-years-of-frontend-then-i-jumped-to-devops-the-roadmap-id-actually-follow-in-2026-1ljg</link>
      <guid>https://dev.to/opscanopy/6-years-of-frontend-then-i-jumped-to-devops-the-roadmap-id-actually-follow-in-2026-1ljg</guid>
      <description>&lt;p&gt;It was a Friday around 11pm and I was three hours into a build that wouldn't go green on a colleague's machine. Not a bug in my code. A version mismatch in a Webpack loader, a Node engine warning, and a peer-dependency tree that looked like the Bengaluru traffic on a Monday morning. I remember thinking: I've been doing React for six years and I still spend half my week fighting the &lt;em&gt;machine my code runs on&lt;/em&gt;, not the code itself.&lt;/p&gt;

&lt;p&gt;That night I started reading about Docker. Not to become a DevOps engineer. Just to make the pain stop.&lt;/p&gt;

&lt;p&gt;Eighteen months later I was on-call for a payments platform, writing Terraform, and getting paid noticeably more to do work I actually liked. So this is the post I wish someone had handed me on that Friday.&lt;/p&gt;

&lt;p&gt;Frontend skills transfer better than you think. Learn Linux and networking &lt;em&gt;first&lt;/em&gt; (boring, non-negotiable), then Docker, then one cloud, then CI/CD, then Kubernetes &lt;em&gt;only when you have a reason&lt;/em&gt;. Skip the certification rabbit hole until you've shipped something real. The full ordered path is at the end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffclevkxvb4lluq6p793q.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffclevkxvb4lluq6p793q.jpeg" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a frontend dev is weirdly well-suited for this
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody told me: you're not starting from zero. You're starting from the side.&lt;/p&gt;

&lt;p&gt;Years of frontend quietly taught you a bunch of DevOps muscle. You've debugged async race conditions, which is distributed-systems thinking in a smaller box. You've stared at network tabs and waterfall charts, so latency and request lifecycles aren't scary. You've fought caching bugs, which is half of what makes production hard. And you already live in the terminal, in Git, in YAML config files that lie to you.&lt;/p&gt;

&lt;p&gt;What you're missing is mostly the layer &lt;em&gt;below&lt;/em&gt; the browser. The server. The network. The box your app actually runs on.&lt;/p&gt;

&lt;p&gt;That's a much smaller gap than the "I'm switching careers from scratch" story your imposter syndrome is telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff I wasted time on (so you don't have to)
&lt;/h2&gt;

&lt;p&gt;Let me be honest about the dead-ends, because the happy-path roadmaps never are.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I tried to learn Kubernetes in month two.&lt;/strong&gt; Total disaster. I was memorizing &lt;code&gt;kubectl&lt;/code&gt; commands for a system I had no mental model for. It's like learning to parallel-park a truck before you can drive a scooter. Kubernetes solves problems you haven't felt yet. Feel the problems first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I collected certifications like Pokémon cards.&lt;/strong&gt; I did a cloud cert before I'd ever deployed a single real thing. The exam taught me to recognize service names, not to use them. I forgot 70% of it within a month because none of it was attached to a real memory of &lt;em&gt;doing&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I treated "DevOps" as a tool checklist.&lt;/strong&gt; I thought the job was knowing 40 tools. It isn't. The job is shrinking the gap between "code written" and "code running safely in front of users." Tools are just how you do that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest waste, though, was skipping Linux fundamentals because I assumed I "basically knew" the terminal from frontend work. I did not. Knowing &lt;code&gt;cd&lt;/code&gt; and &lt;code&gt;npm run dev&lt;/code&gt; is not knowing Linux.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Real talk: the day I understood file permissions, processes, and how stdout/stderr actually work, half of DevOps stopped feeling like magic and started feeling like plumbing. Good plumbing. Knowable plumbing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The order that actually worked
&lt;/h2&gt;

&lt;p&gt;So here's the sequence, the one I'd run again if I had to restart tomorrow. Each step earns the next one. Don't jump ahead just because a job ad mentioned a shiny tool.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Linux + the shell (3–5 weeks).&lt;/strong&gt; Files, permissions, processes, &lt;code&gt;systemd&lt;/code&gt;, package managers, piping, writing a basic bash script that doesn't fall over. This is the floor everything else stands on. I put together the version of this I wish I'd had in the &lt;a href="https://opscanopy.com/learn/guides/linux-for-devops" rel="noopener noreferrer"&gt;Linux for DevOps guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking basics (2–3 weeks).&lt;/strong&gt; DNS, HTTP, TCP/IP, ports, what a subnet actually is, how a load balancer routes. You don't need a CCNA. You need to stop being scared of a CIDR block. (When I was wrapping my head around subnets I kept a &lt;a href="https://opscanopy.com/subnet-calculator" rel="noopener noreferrer"&gt;subnet calculator&lt;/a&gt; open in a tab. No shame in that.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker (3–4 weeks).&lt;/strong&gt; Images vs containers, layers, volumes, networking, a sane &lt;code&gt;Dockerfile&lt;/code&gt;, then &lt;code&gt;docker compose&lt;/code&gt; for multi-service local setups. This is the step where frontend devs have their first "ohhh" moment, because it kills the "works on my machine" problem you've hated for years. Start with the &lt;a href="https://opscanopy.com/learn/guides/docker-for-devops" rel="noopener noreferrer"&gt;Docker for DevOps guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One cloud, and go deep on AWS (6–8 weeks).&lt;/strong&gt; Not all three. One. IAM, EC2, VPC, S3, then RDS and Lambda. Most of the market runs on AWS, so the leverage is highest there. The &lt;a href="https://opscanopy.com/learn/guides/aws-for-devops-engineers" rel="noopener noreferrer"&gt;AWS for DevOps walkthrough&lt;/a&gt; covers the services that actually show up in the job, not all 200+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD (2–3 weeks).&lt;/strong&gt; GitHub Actions is a gentle on-ramp because you already know Git and YAML. Build a pipeline that tests, builds a Docker image, and deploys it somewhere. This is the moment it all clicks into one loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure as Code (3–4 weeks).&lt;/strong&gt; Terraform. Stop clicking buttons in the AWS console; describe your infra in code, review it like code, version it like code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes, last, and only when you need it (ongoing).&lt;/strong&gt; Now you have the mental model. Pods, services, deployments, ingress will make sense because you've felt the problems they solve. The &lt;a href="https://opscanopy.com/learn/guides/kubernetes-for-devops" rel="noopener noreferrer"&gt;Kubernetes for DevOps guide&lt;/a&gt; is where I'd start once you're here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; in the early steps? Kubernetes. Service meshes. Helm charts. That stuff is real and worth learning, just later. Front-loading it is the single most common way people burn out before they get anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvquhtl7j9qvg3b5qddhf.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvquhtl7j9qvg3b5qddhf.jpeg" alt="path for devops" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How long does this really take?
&lt;/h2&gt;

&lt;p&gt;If you're doing this on the side while holding a frontend job, say 8 to 10 hours a week, I'd budget &lt;strong&gt;six to nine months&lt;/strong&gt; to get genuinely employable in a junior-ish DevOps or platform role. Not six weeks. Anyone selling you six weeks is selling you something.&lt;/p&gt;

&lt;p&gt;The good news is you can earn while you learn by doing it &lt;em&gt;inside&lt;/em&gt; your current job. Volunteer to own the team's CI pipeline. Dockerize the dev environment nobody wants to touch. Fix the deploy that breaks every other Friday. You become the "infra person" on a frontend team long before your title changes, and that internal track record is worth more than any cert.&lt;/p&gt;

&lt;p&gt;On money, since everyone asks and nobody answers honestly: from what I've seen and from public salary data in India, DevOps/SRE roles tend to sit a notch above equivalent frontend roles. Rough ranges I've seen floating around for the Indian market: early-career landing somewhere in the ₹8–15 LPA band, mid-level often ₹18–30 LPA, and seniors/SREs in good product companies in Bengaluru, Hyderabad or Pune going well beyond that. Treat those as ballpark, not gospel; they swing hard with company, location, and whether it's a product firm or a services shop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift that mattered most
&lt;/h2&gt;

&lt;p&gt;Frontend trained me to optimize for the happy path. Make the thing work, make it pretty, ship it.&lt;/p&gt;

&lt;p&gt;DevOps trained me to ask the uncomfortable second question: &lt;em&gt;what happens when this fails at 2am?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That reframe is the actual job. It's why on-call exists, why we obsess over observability, why "it works on my machine" is a punchline. You stop thinking about the moment of success and start thinking about the whole lifecycle: deploy, run, fail, recover, repeat. Once that clicks, you read systems differently. A payment that fails silently at 2am during an IPL-final traffic spike is a very different problem than a button that's the wrong shade of green.&lt;/p&gt;

&lt;p&gt;Both matter. But one of them gets you a pager and a raise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2f6a9zlj9mzqp9ch5wqw.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2f6a9zlj9mzqp9ch5wqw.jpeg" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my Friday-night self
&lt;/h2&gt;

&lt;p&gt;You're not behind. You're not too "frontend" for this. The treadmill you're tired of, the framework churn and the build-tool roulette, is exactly the friction DevOps exists to remove. You already feel the pain that this whole field is built to solve. That's not a disadvantage. That's a head start.&lt;/p&gt;

&lt;p&gt;Just go in the right order. Linux before Kubernetes. Doing before certs. Depth in one cloud before breadth across three.&lt;/p&gt;

&lt;p&gt;I eventually got tired of stitching together scattered tutorials, half-right YouTube playlists, and Reddit threads from 2019, so I put the path I wish I'd had into a single free, structured plan. No signup wall, no upsell. If you want a map instead of a pile of bookmarks, start with the &lt;a href="https://opscanopy.com/learn/roadmaps/devops" rel="noopener noreferrer"&gt;DevOps roadmap&lt;/a&gt; and browse the rest of the &lt;a href="https://opscanopy.com/learn" rel="noopener noreferrer"&gt;free guides and roadmaps in the Learn hub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pick step one. Open a terminal. Start the timer. Future-you, the one who isn't debugging a Webpack loader at 11pm, says thanks.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>career</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your GitHub Actions Workflow Didn't Trigger: branches, tags &amp; paths Filters Explained</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:22:10 +0000</pubDate>
      <link>https://dev.to/opscanopy/why-your-github-actions-workflow-didnt-trigger-branches-tags-paths-filters-explained-1ib</link>
      <guid>https://dev.to/opscanopy/why-your-github-actions-workflow-didnt-trigger-branches-tags-paths-filters-explained-1ib</guid>
      <description>&lt;p&gt;You pushed a commit, opened the Actions tab, and there's nothing there. No red X, no yellow dot — the workflow simply didn't run. There's no error to read, no log to grep, because a workflow that doesn't trigger produces no run at all. The decision happened before any runner was assigned, inside GitHub's event-filtering logic, and that logic is more surprising than the docs make it look.&lt;/p&gt;

&lt;p&gt;Almost every "why did my GitHub Actions workflow not trigger" report comes down to one of a handful of causes: the workflow file isn't on the branch you pushed to, your &lt;code&gt;branches&lt;/code&gt; filter doesn't match the ref, or — the big one — you combined &lt;code&gt;branches&lt;/code&gt; and &lt;code&gt;paths&lt;/code&gt; without realizing they're ANDed together. Here's each cause with the deciding rule and the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The workflow file isn't on the target branch
&lt;/h2&gt;

&lt;p&gt;GitHub reads &lt;code&gt;on:&lt;/code&gt; triggers from the version of the workflow file &lt;strong&gt;that exists on the branch receiving the event&lt;/strong&gt; — not from your default branch. If you added &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; on &lt;code&gt;main&lt;/code&gt; but push to a &lt;code&gt;feature/x&lt;/code&gt; branch that branched off &lt;em&gt;before&lt;/em&gt; that file existed, there's no workflow to trigger there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# on main, but feature/x branched before this file existed&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most common false alarm. The fix is mechanical: merge or rebase &lt;code&gt;main&lt;/code&gt; into the branch so the workflow file is present, then push again. The same rule explains why edits to &lt;code&gt;on:&lt;/code&gt; triggers only "take effect" once the change reaches the branch you're testing on.&lt;/p&gt;

&lt;p&gt;Why it matters: there is no error message for "no workflow file here." It's the first thing to rule out before you suspect your filters.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The branch filter doesn't match the ref
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;branches&lt;/code&gt; and &lt;code&gt;tags&lt;/code&gt; are glob patterns, and the glob rules are stricter than shell globs. A plain &lt;code&gt;*&lt;/code&gt; matches &lt;strong&gt;one path segment&lt;/strong&gt; — it stops at &lt;code&gt;/&lt;/code&gt;. To match across slashes you need &lt;code&gt;**&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — '*' does not cross '/', so 'release/1.2' never matches&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/*'&lt;/span&gt;   &lt;span class="c1"&gt;# matches release/1.2 ... actually this IS fine&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feature*'&lt;/span&gt;    &lt;span class="c1"&gt;# matches 'feature' and 'featureX' but NOT 'feature/login'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is &lt;code&gt;feature*&lt;/code&gt; versus &lt;code&gt;feature/**&lt;/code&gt;. &lt;code&gt;feature*&lt;/code&gt; matches the literal segment &lt;code&gt;featureX&lt;/code&gt;, but a branch named &lt;code&gt;feature/login&lt;/code&gt; contains a slash, and &lt;code&gt;*&lt;/code&gt; won't cross it. You want &lt;code&gt;feature/**&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — ** crosses slashes&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feature/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The glob characters GitHub honors: &lt;code&gt;*&lt;/code&gt; (any chars except &lt;code&gt;/&lt;/code&gt;), &lt;code&gt;**&lt;/code&gt; (any chars including &lt;code&gt;/&lt;/code&gt;), &lt;code&gt;?&lt;/code&gt; (one char), &lt;code&gt;+&lt;/code&gt; (one or more of the preceding), &lt;code&gt;[]&lt;/code&gt; character ranges, &lt;code&gt;!&lt;/code&gt; at the start of a pattern to negate, and &lt;code&gt;\&lt;/code&gt; to escape a special character (so &lt;code&gt;\*&lt;/code&gt; matches a literal asterisk). Order matters for negation — a later &lt;code&gt;!pattern&lt;/code&gt; excludes refs an earlier pattern included.&lt;/p&gt;

&lt;p&gt;Why it matters: &lt;code&gt;*&lt;/code&gt; not crossing &lt;code&gt;/&lt;/code&gt; is responsible for a huge share of "github actions branches filter not working" reports. When in doubt, reach for &lt;code&gt;**&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The AND-semantics of &lt;code&gt;branches&lt;/code&gt; + &lt;code&gt;paths&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one that burns experienced engineers. When a &lt;code&gt;push&lt;/code&gt; or &lt;code&gt;pull_request&lt;/code&gt; event has &lt;strong&gt;both&lt;/strong&gt; a branch filter and a path filter, the event must satisfy &lt;strong&gt;both&lt;/strong&gt; to trigger. They are ANDed, not ORed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — intent: "run on a push to main, OR when src changes"&lt;/span&gt;
&lt;span class="c1"&gt;# reality: "run only on a push to main AND when src/** changed"&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A push to &lt;code&gt;main&lt;/code&gt; that only touches &lt;code&gt;README.md&lt;/code&gt; will &lt;strong&gt;not&lt;/strong&gt; run this workflow — the branch matched, but no path did, and both must hold. People read this block as an OR and are baffled when docs-only commits skip CI.&lt;/p&gt;

&lt;p&gt;If you genuinely want "main pushes always, plus any branch when &lt;code&gt;src&lt;/code&gt; changes," that's two separate filter sets, which &lt;code&gt;on:&lt;/code&gt; can't express in one &lt;code&gt;push&lt;/code&gt; block — you split it across triggers or use job-level &lt;code&gt;if:&lt;/code&gt; conditions on &lt;code&gt;github.ref&lt;/code&gt; instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — be explicit that you want both conditions, or drop one&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/**'&lt;/span&gt;   &lt;span class="c1"&gt;# so CI changes still trigger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: the AND-semantics are documented in one sentence and contradict most people's intuition. If your workflow "randomly" skips some pushes to the right branch, a path filter is almost always the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;paths&lt;/code&gt; with no &lt;code&gt;branches&lt;/code&gt; companion still needs a real ref
&lt;/h2&gt;

&lt;p&gt;A subtle corollary: when you filter &lt;code&gt;on.push.paths&lt;/code&gt; and want it to apply across all branches, you don't need a &lt;code&gt;branches&lt;/code&gt; block at all — omitting it means "all branches." But the moment you add &lt;code&gt;branches&lt;/code&gt;, rule #3 kicks in. People sometimes add &lt;code&gt;branches: ['**']&lt;/code&gt; thinking it's required for &lt;code&gt;paths&lt;/code&gt; to work; it isn't, and adding it changes nothing because &lt;code&gt;**&lt;/code&gt; matches every branch anyway. The thing to internalize is that a missing filter means "match everything," and a present filter narrows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# These behave identically: paths applies to every branch&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# vs&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. &lt;code&gt;paths-ignore&lt;/code&gt; and the diff that's too big
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;paths-ignore&lt;/code&gt; skips the run &lt;strong&gt;only if every changed file matches an ignore pattern&lt;/strong&gt;. If a single file falls outside the ignore list, the workflow runs. So one stray change defeats the whole filter — which is usually what you want, but surprises people who expect "ignore these files" to mean "ignore commits that touch these files."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Skips ONLY when every changed file is docs; one code file =&amp;gt; runs&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;docs/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more gotchas live here. First, path filters are evaluated against the &lt;strong&gt;diff&lt;/strong&gt;, and GitHub only inspects up to 300 changed files (1,000 commits) — beyond that limit, path filtering gives up and the workflow runs (or is evaluated as if the filter passed). A giant force-push or a huge merge can trigger a workflow your &lt;code&gt;paths-ignore&lt;/code&gt; "should" have skipped. Second, you cannot mix &lt;code&gt;paths&lt;/code&gt; and &lt;code&gt;paths-ignore&lt;/code&gt; in the same trigger; pick one.&lt;/p&gt;

&lt;p&gt;Why it matters: &lt;code&gt;paths-ignore&lt;/code&gt; is an all-or-nothing gate on the diff, and the 300-file ceiling means it's not a hard guarantee on large changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;pull_request&lt;/code&gt;, forks, and &lt;code&gt;pull_request_target&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Branch filters on &lt;code&gt;pull_request&lt;/code&gt; match the &lt;strong&gt;base&lt;/strong&gt; branch (where the PR will merge), not the head branch the contributor is working on. If you write &lt;code&gt;branches: [main]&lt;/code&gt; expecting it to match the contributor's &lt;code&gt;feature/x&lt;/code&gt;, it won't — it matches PRs &lt;em&gt;targeting&lt;/em&gt; &lt;code&gt;main&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Runs on PRs whose BASE (merge target) is main or a release branch&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/**'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;pull_request&lt;/code&gt; from a fork is restricted: a first-time contributor's PR may require manual approval before any workflow runs, which looks identical to "didn't trigger." If you switched to &lt;code&gt;pull_request_target&lt;/code&gt; to get around fork restrictions, note that it reads the workflow and triggers from the &lt;strong&gt;base&lt;/strong&gt; branch's version of the file — and carries real security risk, covered in our &lt;a href="https://dev.to/blog/github-actions-security-misconfigurations"&gt;GitHub Actions security misconfigurations&lt;/a&gt; post.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy-paste filter cheat-sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# ref globs; missing = all branches&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/**'&lt;/span&gt;          &lt;span class="c1"&gt;# ** crosses '/'; '*' does not&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feature/**'&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v[0-9]+.[0-9]+.[0-9]+'&lt;/span&gt;   &lt;span class="c1"&gt;# numeric semver tags only&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# ANDed with branches — BOTH must match&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/**'&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# matches the PR's BASE branch&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;             &lt;span class="c1"&gt;# skip only if EVERY changed file matches&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick reference for the glob characters: &lt;code&gt;*&lt;/code&gt; = any chars except &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;**&lt;/code&gt; = any chars including &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt; = one char, &lt;code&gt;+&lt;/code&gt; = one-or-more of the preceding, &lt;code&gt;[a-z]&lt;/code&gt; = range, leading &lt;code&gt;!&lt;/code&gt; = negate, &lt;code&gt;\&lt;/code&gt; = escape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop guessing — replay your event
&lt;/h2&gt;

&lt;p&gt;The reason these bugs are maddening is that the feedback loop is "push and pray." There's no dry-run, no &lt;code&gt;--explain&lt;/code&gt;, just an empty Actions tab. So you commit a one-line change, push, refresh, wait, and repeat — burning minutes per guess against semantics you're not sure of.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;GitHub Actions Expression &amp;amp; Trigger Tester&lt;/strong&gt; closes that loop. Paste your &lt;code&gt;on:&lt;/code&gt; block, describe the event — &lt;code&gt;push&lt;/code&gt; to &lt;code&gt;feature/login&lt;/code&gt;, tag &lt;code&gt;v2.1.0&lt;/code&gt;, or a &lt;code&gt;pull_request&lt;/code&gt; targeting &lt;code&gt;main&lt;/code&gt; with a list of changed files — and it evaluates every &lt;code&gt;branches&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;paths&lt;/code&gt;, and &lt;code&gt;paths-ignore&lt;/code&gt; filter with the same glob engine and AND-semantics GitHub uses. You get a per-job &lt;strong&gt;RUNS / SKIPPED&lt;/strong&gt; table with the exact deciding reason: "branch matched, but no path filter did," or "&lt;code&gt;*&lt;/code&gt; does not cross &lt;code&gt;/&lt;/code&gt;." It's 100% in your browser — your workflow YAML never leaves the page.&lt;/p&gt;

&lt;p&gt;See exactly which jobs run before you push, not after.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opscanopy.com/github-actions-validator/" rel="noopener noreferrer"&gt;Open the GitHub Actions Expression &amp;amp; Trigger Tester →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>cicd</category>
      <category>githubactions</category>
      <category>github</category>
    </item>
    <item>
      <title>Why Your GitHub Actions "if" Condition Always Runs (and How to Fix It</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:18:07 +0000</pubDate>
      <link>https://dev.to/opscanopy/why-your-github-actions-if-condition-always-runs-and-how-to-fix-it-b6e</link>
      <guid>https://dev.to/opscanopy/why-your-github-actions-if-condition-always-runs-and-how-to-fix-it-b6e</guid>
      <description>&lt;p&gt;You added an &lt;code&gt;if:&lt;/code&gt; to a step so it would only run on &lt;code&gt;main&lt;/code&gt;, or only on a tag, or only when a previous step set an output. Then you pushed — and the step ran anyway. Every time. On every branch. The condition is just decoration.&lt;/p&gt;

&lt;p&gt;If your GitHub Actions &lt;code&gt;if&lt;/code&gt; condition is not working — specifically, if it &lt;em&gt;always&lt;/em&gt; evaluates to true — you almost certainly hit the single most common footgun in the whole product: &lt;strong&gt;putting literal text where GitHub expects an expression.&lt;/strong&gt; The runner doesn't error on it. It quietly coerces your text to a non-empty string, decides a non-empty string is truthy, and runs the step. This post shows the exact bad patterns, the fixes, and the coercion rules underneath so you stop guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The footgun: literal text outside &lt;code&gt;${{ }}&lt;/code&gt; is always truthy
&lt;/h2&gt;

&lt;p&gt;In an &lt;code&gt;if:&lt;/code&gt;, GitHub already evaluates the value as an expression — you do &lt;strong&gt;not&lt;/strong&gt; wrap the whole thing in &lt;code&gt;${{ }}&lt;/code&gt;. But the moment any literal text leaks outside the expression braces, the runner stops treating the line as a condition and starts treating it as a string. A non-empty string is truthy. Your step always runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — the ${{ }} is embedded in a larger string, so the whole if: is a string&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref == 'refs/heads/main' }} &amp;amp;&amp;amp; success()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks reasonable, but the runner sees: evaluate &lt;code&gt;${{ ... }}&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;, then concatenate &lt;code&gt;&amp;amp;&amp;amp; success()&lt;/code&gt; as &lt;strong&gt;literal text&lt;/strong&gt;. The final value is the string &lt;code&gt;"true &amp;amp;&amp;amp; success()"&lt;/code&gt; — non-empty, therefore truthy. The step runs on every branch.&lt;/p&gt;

&lt;p&gt;The fix is to write &lt;strong&gt;one&lt;/strong&gt; expression with no braces and no stray text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — a single bare expression, no ${{ }}, no trailing literal&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' &amp;amp;&amp;amp; success()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trap catches you when you quote the &lt;em&gt;whole&lt;/em&gt; condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — the entire condition is a quoted string literal, always truthy&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;steps.check.outputs.changed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'true'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./build.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping the expression in quotes makes the YAML value a plain string. GitHub finds a &lt;code&gt;${{ }}&lt;/code&gt; inside it, substitutes the result, and you're back to a non-empty string. Drop the quotes and the braces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.check.outputs.changed == 'true'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./build.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: &lt;strong&gt;in an &lt;code&gt;if:&lt;/code&gt;, there are no &lt;code&gt;${{ }}&lt;/code&gt; and no surrounding quotes.&lt;/strong&gt; Just the expression. The braces are for interpolating values into &lt;code&gt;run:&lt;/code&gt;, &lt;code&gt;name:&lt;/code&gt;, and &lt;code&gt;with:&lt;/code&gt; — not for conditions.&lt;/p&gt;

&lt;p&gt;You can paste either of these into the &lt;a href="https://dev.to/github-actions-expression-tester"&gt;GitHub Actions Expression &amp;amp; Trigger Tester&lt;/a&gt; and watch it flag the literal-text leak before you push — it warns on exactly this pattern (it's tracked as &lt;a href="https://github.com/actions/runner/issues/1173" rel="noopener noreferrer"&gt;actions/runner#1173&lt;/a&gt;, the most-reacted bug in the runner repo).&lt;/p&gt;

&lt;h2&gt;
  
  
  The implicit &lt;code&gt;success()&lt;/code&gt; that vanishes when you add an &lt;code&gt;if:&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the second surprise, and it's the reason "my conditional step runs even though the previous step failed."&lt;/p&gt;

&lt;p&gt;Every step and job has an &lt;strong&gt;implicit &lt;code&gt;success()&lt;/code&gt; condition&lt;/strong&gt;. With no &lt;code&gt;if:&lt;/code&gt; at all, a step runs only if everything before it succeeded. That's why pipelines stop on the first failure without you writing anything.&lt;/p&gt;

&lt;p&gt;The instant you add a &lt;em&gt;custom&lt;/em&gt; &lt;code&gt;if:&lt;/code&gt;, that implicit &lt;code&gt;success()&lt;/code&gt; is &lt;strong&gt;gone&lt;/strong&gt;. Your condition is now the &lt;em&gt;whole&lt;/em&gt; truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — you wanted "on main", but you deleted the implicit success() guard&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Notify on main&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./notify.sh&lt;/span&gt;   &lt;span class="c1"&gt;# now runs on main EVEN IF the build above failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you still want the step to require success, say so explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — re-add the success() guard you lost&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Notify on main&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success() &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./notify.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also why people are confused that a "cleanup" step runs only on success when they wanted it to run no matter what — the implicit guard is still there until they add &lt;code&gt;always()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;success()&lt;/code&gt; vs &lt;code&gt;always()&lt;/code&gt; vs &lt;code&gt;failure()&lt;/code&gt; vs &lt;code&gt;cancelled()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;These four status functions decide &lt;em&gt;whether the step considers prior results at all&lt;/em&gt;. Mixing them up is the other half of "my &lt;code&gt;if&lt;/code&gt; doesn't behave."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;success()&lt;/code&gt;&lt;/strong&gt; — true only if all previous steps/jobs succeeded. (This is the implicit default.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;failure()&lt;/code&gt;&lt;/strong&gt; — true if any previous step failed. Use it for failure notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;always()&lt;/code&gt;&lt;/strong&gt; — true unconditionally; the step runs even if a prior step failed &lt;em&gt;or the workflow was cancelled&lt;/em&gt;. Use for cleanup that must always happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cancelled()&lt;/code&gt;&lt;/strong&gt; — true only when the workflow was cancelled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The classic mistake is combining &lt;code&gt;always()&lt;/code&gt; with another condition using &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; and expecting it to still run on cancellation — it does, but people often want the opposite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — "always upload logs, but only on main" — this does NOT short-circuit on failure&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload logs&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./upload-logs.sh&lt;/span&gt;   &lt;span class="c1"&gt;# skipped when the build fails, because implicit success() is gone... wait, no — it's gone, so it runs? See below.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be precise about that last one: because you supplied a custom &lt;code&gt;if:&lt;/code&gt;, the implicit &lt;code&gt;success()&lt;/code&gt; is dropped, so the step runs on &lt;code&gt;main&lt;/code&gt; &lt;em&gt;regardless&lt;/em&gt; of whether the build passed. If you actually want "upload logs on main, pass or fail," that's what you have — but make the intent explicit so the next reader isn't guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — explicit: run on main whether the build passed or failed&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload logs&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always() &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./upload-logs.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for a failure-only alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — only when something upstream broke&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Alert&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./page-oncall.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Coercion surprises: &lt;code&gt;==&lt;/code&gt;, strings, and case-insensitivity
&lt;/h2&gt;

&lt;p&gt;Even with correctly-formed expressions, GitHub's comparison rules trip people up because they're JavaScript-&lt;em&gt;like&lt;/em&gt; but not JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;String &lt;code&gt;==&lt;/code&gt; is case-insensitive.&lt;/strong&gt; This burns people comparing branch refs or input values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Surprise: both of these are TRUE&lt;/span&gt;
&lt;span class="s"&gt;${{ 'MAIN' == 'main' }}&lt;/span&gt;          &lt;span class="c1"&gt;# true — case-insensitive&lt;/span&gt;
&lt;span class="s"&gt;${{ 'Refs/Heads/Main' == github.ref }}&lt;/span&gt;  &lt;span class="c1"&gt;# may be true unexpectedly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Loose coercion across types.&lt;/strong&gt; When the two sides differ in type, GitHub coerces toward a number: booleans become &lt;code&gt;1&lt;/code&gt;/&lt;code&gt;0&lt;/code&gt;, and strings are parsed as numbers (an empty string and &lt;code&gt;'0'&lt;/code&gt; are &lt;code&gt;0&lt;/code&gt;; non-numeric strings become &lt;code&gt;NaN&lt;/code&gt;, and any comparison with &lt;code&gt;NaN&lt;/code&gt; is false). So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;${{ &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt; == 1 }}&lt;/span&gt;        &lt;span class="c1"&gt;# true&lt;/span&gt;
&lt;span class="s"&gt;${{ '' == 0 }}&lt;/span&gt;          &lt;span class="c1"&gt;# true  — empty string coerces to 0&lt;/span&gt;
&lt;span class="s"&gt;${{ '3.0' == 3 }}&lt;/span&gt;       &lt;span class="c1"&gt;# true&lt;/span&gt;
&lt;span class="s"&gt;${{ 'abc' == 0 }}&lt;/span&gt;       &lt;span class="c1"&gt;# false — 'abc' is NaN, NaN != anything&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; and &lt;code&gt;||&lt;/code&gt; return operands, not booleans.&lt;/strong&gt; Just like JavaScript, &lt;code&gt;a &amp;amp;&amp;amp; b&lt;/code&gt; returns &lt;code&gt;b&lt;/code&gt; if &lt;code&gt;a&lt;/code&gt; is truthy, otherwise &lt;code&gt;a&lt;/code&gt;. This is great for defaults (&lt;code&gt;inputs.name || 'default'&lt;/code&gt;) but means &lt;code&gt;if: inputs.flag &amp;amp;&amp;amp; 'yes'&lt;/code&gt; evaluates to the string &lt;code&gt;'yes'&lt;/code&gt; — truthy — not a clean boolean.&lt;/p&gt;

&lt;p&gt;The falsy values are exactly: &lt;code&gt;false&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;''&lt;/code&gt; (empty string), and &lt;code&gt;null&lt;/code&gt;. Everything else — including the strings &lt;code&gt;'false'&lt;/code&gt; and &lt;code&gt;'0'&lt;/code&gt;... wait: &lt;code&gt;'0'&lt;/code&gt; is falsy because it coerces to the number &lt;code&gt;0&lt;/code&gt;, but &lt;code&gt;'false'&lt;/code&gt; is a &lt;strong&gt;non-empty string that does not coerce to a number&lt;/strong&gt;, so &lt;code&gt;${{ 'false' }}&lt;/code&gt; is &lt;strong&gt;truthy&lt;/strong&gt;. That single fact causes more "my boolean input is always true" bugs than any other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — workflow_dispatch inputs are STRINGS; 'false' is truthy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;boolean&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;go&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inputs.deploy&lt;/span&gt;   &lt;span class="c1"&gt;# with type: boolean this is fine...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — but if the value arrives as a string 'false', this always runs&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.inputs.deploy&lt;/span&gt;   &lt;span class="c1"&gt;# string 'false' is truthy!&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — compare explicitly so the string is interpreted as data&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.inputs.deploy == 'true'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;code&gt;contains&lt;/code&gt; and &lt;code&gt;startsWith&lt;/code&gt; aren't the same as &lt;code&gt;==&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Filtering by ref prefix is another spot where the wrong function silently over-matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — contains matches ANYWHERE, so 'feature/main-fix' passes too&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;contains(github.ref, 'main')&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — anchor to the start, or compare the full ref&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;startsWith(github.ref, 'refs/heads/release/')&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;span class="c1"&gt;# or, for an exact branch:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember both &lt;code&gt;contains&lt;/code&gt; and &lt;code&gt;startsWith&lt;/code&gt; do string comparison case-insensitively, same as &lt;code&gt;==&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test your &lt;code&gt;if:&lt;/code&gt; before you push
&lt;/h2&gt;

&lt;p&gt;The reason these bugs are so persistent is the feedback loop: the only way to "test" a condition has traditionally been to commit, push, and read the logs — then guess, edit, and push again. Every wrong guess is a round-trip.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/github-actions-expression-tester"&gt;GitHub Actions Expression &amp;amp; Trigger Tester&lt;/a&gt; closes that loop. Paste your &lt;code&gt;if:&lt;/code&gt; expression, set a mock &lt;code&gt;github&lt;/code&gt; / &lt;code&gt;env&lt;/code&gt; / &lt;code&gt;steps&lt;/code&gt; / &lt;code&gt;needs&lt;/code&gt; context, and see the evaluated result with GitHub's exact operator, coercion, and case-insensitivity rules — plus an explicit warning when you've left literal text outside &lt;code&gt;${{ }}&lt;/code&gt; and accidentally built an always-truthy condition. It runs entirely in your browser; nothing about your workflow is uploaded.&lt;/p&gt;

&lt;p&gt;If you've ever shipped an &lt;code&gt;if:&lt;/code&gt; and hoped it would skip, this is the check that tells you before the runner does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;Try the GitHub Actions Expression &amp;amp; Trigger Tester →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>cicd</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>One CVE, four ignore files: unifying Trivy, Grype, Snyk and osv-scanner</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:26:39 +0000</pubDate>
      <link>https://dev.to/opscanopy/one-cve-four-ignore-files-unifying-trivy-grype-snyk-and-osv-scanner-4i0m</link>
      <guid>https://dev.to/opscanopy/one-cve-four-ignore-files-unifying-trivy-grype-snyk-and-osv-scanner-4i0m</guid>
      <description>&lt;p&gt;You triaged the CVE. A scanner flagged &lt;code&gt;CVE-2023-45853&lt;/code&gt; in zlib, you read the advisory, confirmed the vulnerable code path isn’t reachable from your image, and made the call: suppress it, with a reason and a review date. One decision.&lt;/p&gt;

&lt;p&gt;Now encode that one decision four times.&lt;/p&gt;

&lt;p&gt;If your pipeline runs more than one scanner — and plenty do, because Trivy, Grype, Snyk and osv-scanner each catch things the others miss — every accepted-risk suppression has to be written into every tool’s ignore file. Same CVE, same rationale, four different files with four different schemas, four different capabilities. Get one wrong and that scanner keeps failing the build (or worse, silently stops failing on something you never meant to ignore).&lt;/p&gt;

&lt;h2&gt;
  
  
  The same suppression, four ways
&lt;/h2&gt;

&lt;p&gt;Here’s the simplest version of that decision — “ignore &lt;code&gt;CVE-2023-45853&lt;/code&gt;” — in each format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.trivyignore&lt;/code&gt;&lt;/strong&gt; is a flat newline-delimited list. One ID per line, &lt;code&gt;#&lt;/code&gt; for comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# zlib MiniZip — not reachable from our build, re-review 2026-09-01
CVE-2023-45853
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.grype.yaml&lt;/code&gt;&lt;/strong&gt; uses a structured &lt;code&gt;ignore&lt;/code&gt; array of match rules. You can scope by vulnerability ID and, optionally, by package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;vulnerability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CVE-2023-45853&lt;/span&gt;
    &lt;span class="c1"&gt;# zlib MiniZip — not reachable, re-review 2026-09-01&lt;/span&gt;
    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zlib&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.2.13&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.snyk&lt;/code&gt;&lt;/strong&gt; is Snyk’s policy file: a YAML map keyed by issue ID, where each entry is a list of path-scoped objects carrying &lt;code&gt;reason&lt;/code&gt; and &lt;code&gt;expires&lt;/code&gt; as first-class fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.25.0&lt;/span&gt;
&lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;CVE-2023-45853&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zlib MiniZip not reachable from our build&lt;/span&gt;
        &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-09-01T00:00:00.000Z&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;osv-scanner.toml&lt;/code&gt;&lt;/strong&gt; uses a TOML &lt;code&gt;[[IgnoredVulns]]&lt;/code&gt; array of tables, with &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;reason&lt;/code&gt; and an RFC&amp;nbsp;3339 &lt;code&gt;ignoreUntil&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[IgnoredVulns]]&lt;/span&gt;
&lt;span class="py"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"CVE-2023-45853"&lt;/span&gt;
&lt;span class="py"&gt;ignoreUntil&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="ld"&gt;2026-09-01T00:00:00Z&lt;/span&gt;
&lt;span class="py"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"zlib MiniZip not reachable from our build"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four files. The CVE ID is the only thing that survives verbatim across all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What maps cleanly
&lt;/h2&gt;

&lt;p&gt;A handful of concepts are genuinely portable, and a converter can move them without loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The identifier.&lt;/strong&gt; A &lt;code&gt;CVE-…&lt;/code&gt; ID is a &lt;code&gt;CVE-…&lt;/code&gt; ID everywhere. The one wrinkle: osv-scanner is happiest with OSV IDs (&lt;code&gt;GHSA-…&lt;/code&gt;, &lt;code&gt;PYSEC-…&lt;/code&gt;) but accepts CVEs and resolves aliases, while the others are CVE-first. Most of the time the ID is a clean copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reason string.&lt;/strong&gt; Grype, Snyk and osv-scanner all have somewhere to put “why.” Trivy historically did not — in a bare &lt;code&gt;.trivyignore&lt;/code&gt; the only home for a rationale is a &lt;code&gt;#&lt;/code&gt; comment on the line above, which no tool parses but every reviewer reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry, in spirit.&lt;/strong&gt; Snyk’s &lt;code&gt;expires&lt;/code&gt; and osv-scanner’s &lt;code&gt;ignoreUntil&lt;/code&gt; are both real, enforced timestamps: once the date passes, the suppression lapses and the finding comes back. That’s the same idea, and it converts directly between the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is lossy
&lt;/h2&gt;

&lt;p&gt;The interesting part is everything that &lt;em&gt;doesn’t&lt;/em&gt; round-trip. A converter has to be honest about these instead of silently dropping them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry is asymmetric.&lt;/strong&gt; Snyk and osv-scanner enforce a date. Plain &lt;code&gt;.trivyignore&lt;/code&gt; has no expiry field at all — a suppression there is forever until someone deletes the line. (Trivy’s newer YAML ignore file supports a &lt;code&gt;statement&lt;/code&gt; and richer matching, but the classic &lt;code&gt;.trivyignore&lt;/code&gt; most repos still use does not.) Convert a Snyk ignore &lt;em&gt;with&lt;/em&gt; an expiry into &lt;code&gt;.trivyignore&lt;/code&gt; and that expiry can only survive as a comment — a note to a human, not a rule the scanner enforces. That is a real loss of safety, and it should be flagged, not hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package scope varies.&lt;/strong&gt; Grype lets you pin &lt;code&gt;package.name&lt;/code&gt; and &lt;code&gt;package.version&lt;/code&gt;, so the suppression only applies to that exact dependency. &lt;code&gt;.trivyignore&lt;/code&gt; (classic) keys purely on the vulnerability ID — it ignores that CVE &lt;em&gt;everywhere it appears in the scan&lt;/em&gt;, which is broader than you may have intended. Narrowing a Grype rule down to &lt;code&gt;.trivyignore&lt;/code&gt; widens the blast radius; you can’t express “only this package” in a flat ID list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path scope is mostly a Snyk idea.&lt;/strong&gt; Snyk’s &lt;code&gt;ignore&lt;/code&gt; entries are lists keyed by &lt;em&gt;path&lt;/em&gt; — the &lt;code&gt;"*"&lt;/code&gt; above means “everywhere,” but Snyk can also scope a suppression to a specific dependency path so it applies in one place and not another. None of Trivy, Grype or osv-scanner model path-level scope this way, so a path-scoped Snyk ignore flattens to “this CVE, project-wide” in the others. The intent (“only on this transitive path”) is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason has nowhere native to land in classic Trivy.&lt;/strong&gt; As above — converting &lt;em&gt;into&lt;/em&gt; &lt;code&gt;.trivyignore&lt;/code&gt; demotes a first-class reason to a comment. Converting &lt;em&gt;out of&lt;/em&gt; it, there’s usually no machine-readable reason to recover at all, only whatever a human left in a &lt;code&gt;#&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;The pattern across all of these: converting toward a more expressive format is safe, and converting toward a flatter one quietly broadens scope and drops metadata. The only responsible way to do the flat conversion is to surface exactly what was lost so a human can decide whether the wider suppression is acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it once, emit four
&lt;/h2&gt;

&lt;p&gt;Maintaining four hand-written ignore files in lockstep is exactly the kind of repetitive, error-prone bookkeeping that should be mechanical. Describe a suppression once — ID, reason, expiry, package and path scope — and let a tool emit the correct shape for each scanner, with explicit warnings wherever a target format can’t hold a field.&lt;/p&gt;

&lt;p&gt;That’s what the &lt;strong&gt;CVE-Ignore Converter&lt;/strong&gt; does: paste any one of &lt;code&gt;.trivyignore&lt;/code&gt;, &lt;code&gt;.grype.yaml&lt;/code&gt;, &lt;code&gt;.snyk&lt;/code&gt; or &lt;code&gt;osv-scanner.toml&lt;/code&gt;, get the other three back, and see precisely which parts were lossy in the round-trip. It runs entirely in your browser — your suppression policy never leaves the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/cve-ignore-converter"&gt;Try the CVE-Ignore Converter →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/cve-ignore-converter" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>vulnerabilities</category>
    </item>
  </channel>
</rss>
