<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ci-Cd – Serverküche</title><link>https://serverkueche.de/en/tags/ci-cd/</link><description>Ci-Cd – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Fri, 14 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/ci-cd/index.xml" rel="self" type="application/rss+xml"/><item><title>Forgejo Actions: your own CI/CD runner with Docker</title><link>https://serverkueche.de/en/tutorials/forgejo-actions-runner/</link><pubDate>Fri, 14 Aug 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/forgejo-actions-runner/</guid><description>Set up and register a Forgejo Actions runner with Docker-in-Docker: your own CI/CD pipelines on the self-hosted Git server – step by step.</description><content:encoded><![CDATA[<p>Your <a href="/en/tutorials/forgejo-git-server/">Forgejo Git server</a> is running – but code just sits there as long as no one tests and deploys it. <strong>Forgejo Actions</strong> brings CI/CD right into your Git platform: on every push, tests, builds or deployments run automatically. The work is done by a <strong>runner</strong> you operate yourself. This tutorial sets up such a runner with Docker-in-Docker and lets a first pipeline run green.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, your Forgejo has a <strong>registered, active runner</strong> that executes workflows from the <code>.forgejo/workflows/</code> directory. We rely on <strong>Docker-in-Docker (DinD)</strong>: the runner starts each CI job in its own, disposable container, cleanly isolated from the host. Concretely, by the end this runs:</p>
<ul>
<li>the <strong>Forgejo runner</strong> (<code>code.forgejo.org/forgejo/runner:13.0.0</code>), which asks Forgejo for jobs,</li>
<li>a <strong>Docker-in-Docker sidecar</strong> (<code>docker:29-dind</code>) in which the jobs run isolated,</li>
<li>an example repository with a workflow that runs <strong><code>actions/checkout</code></strong> on every push and starts a small action.</li>
</ul>
<p>Forgejo Actions is largely <strong>compatible with GitHub Actions</strong> – the same workflow syntax, many marketplace actions work unchanged. So you can keep using existing knowledge directly, just on your own server. Tested with <strong>Forgejo 16.0.1</strong> and <strong>Runner v13.0.0</strong> on <strong>Debian 13 / Docker 29</strong>.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <strong>running <a href="/en/tutorials/forgejo-git-server/">Forgejo server</a></strong> behind a reverse proxy, reachable under a <strong>public HTTPS domain</strong> (<code>YOUR_DOMAIN</code>). The public URL is important – more on that below.</li>
<li><strong>Docker</strong> on the same server (the runner and its DinD sidecar run as containers).</li>
<li><strong>Admin access</strong> to Forgejo to generate the registration token.</li>
</ul>
<p>Actions has been enabled by default since <strong>Forgejo 1.21</strong>. If you&rsquo;ve set it explicitly in your Forgejo Compose (recommended), it says there:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__actions__ENABLED</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span></span></span></code></pre></div>
</div>
<p>CI jobs are more resource-hungry than the plain Git server – builds need CPU and RAM. For running the runner alongside Forgejo we therefore recommend a bit more reserve; how much your specific setup needs is estimated by the <a href="/en/server-calculator/">server calculator</a>.</p>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/forgejo-actions-runner/" data-content-piece="VPS 2000 G12">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 2000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">8 vCores · 16 GB RAM · 512 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €19.24/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Forgejo plus a runner and build jobs benefit from the larger plan.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-2000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 2000 G12 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span><code data-track-voucher="36nc17844976032"
        class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-get-the-registration-token">Step 1: Get the registration token</h3>
<p>The runner has to register with Forgejo once. For that you need a <strong>registration token</strong>. The easiest way is via the web interface: log in as administrator and go to <strong>Administration settings → Actions → Runners</strong>. There you see all runners and, at the top right, the button <strong>Create registration token</strong>.</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-actions-runner/forgejo-runner-online_hu_bfa95bf9e9c4a61a.webp" srcset="/en/tutorials/forgejo-actions-runner/forgejo-runner-online_hu_4a29bf1c2238d05c.webp 480w, /en/tutorials/forgejo-actions-runner/forgejo-runner-online_hu_bfa95bf9e9c4a61a.webp 768w, /en/tutorials/forgejo-actions-runner/forgejo-runner-online_hu_f8574dd70a24a0f1.webp 1200w, /en/tutorials/forgejo-actions-runner/forgejo-runner-online_hu_7cfe2ec276f7e37e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-actions-runner/forgejo-runner-online_hu_2c535f2d92989f67.webp"
    alt="The runner management in Forgejo&rsquo;s administration settings with the registered runner." title="Site administration → Actions → Runners: here you get the token and later see the runner status."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Site administration → Actions → Runners: here you get the token and later see the runner status.</figcaption></figure></p>
<p>Alternatively via the command line directly in the Forgejo container:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -u git forgejo forgejo actions generate-runner-token</span></span></code></pre></div>
</div>
<p>That outputs a long token – copy it, you need it once shortly.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tip
  </p>
  <div class="prose-kitchen text-sm">The runner shown here is registered <strong>globally</strong> (for the whole instance). You can also bind runners to just an <strong>organization</strong> or a <strong>single repository</strong> – then you get the token in the respective settings under <em>Actions → Runners</em>. For getting started, a global runner is the most practical.</div>
</div>
<h3 id="step-2-write-the-runner-compose">Step 2: Write the runner Compose</h3>
<p>Create a dedicated folder and change into it:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /opt/forgejo-runner <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/forgejo-runner</span></span></code></pre></div>
</div>
<p>Create the <code>compose.yaml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">docker</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">docker:29-dind</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">fjr-docker</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">privileged</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOCKER_TLS_CERTDIR</span><span class="p">:</span><span class="w"> </span><span class="l">/certs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">dind_certs:/certs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">runner_data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">runner</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">code.forgejo.org/forgejo/runner:13.0.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">fjr-runner</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">docker]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOCKER_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">tcp://docker:2376</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOCKER_CERT_PATH</span><span class="p">:</span><span class="w"> </span><span class="l">/certs/client</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOCKER_TLS_VERIFY</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">dind_certs:/certs:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">runner_data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">working_dir</span><span class="p">:</span><span class="w"> </span><span class="l">/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">forgejo-runner daemon</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">dind_certs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">runner_data:</span></span></span></code></pre></div>
</div>
<p>The most important points:</p>
<ul>
<li>The DinD service is deliberately called <strong><code>docker</code></strong>. Its automatically generated TLS certificate is issued for exactly this name – if the service is called something else, the runner fails with &ldquo;certificate is valid for docker, not …&rdquo; (see &ldquo;When things go wrong&rdquo;).</li>
<li><strong><code>privileged: true</code></strong> is needed by DinD to run its own Docker engine. That&rsquo;s the price of isolation; secure the runner server accordingly.</li>
<li>The runner talks to the DinD via <strong><code>DOCKER_HOST: tcp://docker:2376</code></strong> with TLS; it shares the client certificates via the <code>dind_certs</code> volume.</li>
<li>The registration lands as a <code>.runner</code> file in the <strong><code>runner_data</code></strong> volume and thus survives restarts and updates.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Why Docker-in-Docker?
  </p>
  <div class="prose-kitchen text-sm">The alternative would be to pass the <strong>host&rsquo;s Docker socket</strong> (<code>/var/run/docker.sock</code>) into the runner. That&rsquo;s simpler, but effectively gives the CI jobs <strong>root on the host</strong> – a manipulated workflow could take over the whole server. DinD encapsulates the jobs in their own Docker instance and is the clearly safer choice.</div>
</div>
<h3 id="step-3-start-dind-and-register-the-runner">Step 3: Start DinD and register the runner</h3>
<p>First start only the DinD sidecar so it generates its certificates:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d docker</span></span></code></pre></div>
</div>
<p>Now register the runner <strong>once</strong>. Replace <code>YOUR_TOKEN</code> with the token from step 1:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose run --rm runner forgejo-runner register <span class="se">\
</span></span></span><span class="line"><span class="cl">  --no-interactive <span class="se">\
</span></span></span><span class="line"><span class="cl">  --instance https://YOUR_DOMAIN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --token YOUR_TOKEN <span class="se">\
</span></span></span><span class="line"><span class="cl">  --name my-runner <span class="se">\
</span></span></span><span class="line"><span class="cl">  --labels <span class="s2">&#34;docker:docker://node:24-bookworm&#34;</span></span></span></code></pre></div>
</div>
<p>On success the output ends with <code>Runner registered successfully.</code> (a warning that <code>register</code> is &ldquo;deprecated&rdquo; you can ignore – it works).</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Definitely use the public URL
  </p>
  <div class="prose-kitchen text-sm">Register the runner with your <strong>public</strong> address (<code>https://YOUR_DOMAIN</code>) – <strong>not</strong> an internal one like <code>http://forgejo:3000</code>. Reason: the CI jobs run in DinD in their own containers with their <strong>own network</strong> and can&rsquo;t resolve internal Docker names. But when checking out, they have to reach the Git server. With the public URL that works from anywhere – with an internal name, every job fails at <code>checkout</code>.</div>
</div>
<p>The label <code>docker:docker://node:24-bookworm</code> means: jobs with <code>runs-on: docker</code> are executed in a <code>node:24-bookworm</code> container (brings Node.js and the usual build tools). Node 24 is the currently active LTS line – Node 20 has been out of support since April 2026.</p>
<h3 id="step-4-start-the-runner-and-check-the-status">Step 4: Start the runner and check the status</h3>
<p>Now start the whole stack:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>Check that both containers are running:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<p>Take a look at the runner log – here you see whether the registration worked:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose logs runner</span></span></code></pre></div>
</div>
<p>You should see a line like <code>declared successfully</code> and <code>[poller] launched</code> – the runner now actively asks Forgejo for jobs. In the web interface under <strong>Administration settings → Actions → Runners</strong>, <code>my-runner</code> appears with a <strong>green status dot</strong> and the label <code>docker</code> (see screenshot above). If it&rsquo;s <code>Idle</code> with a green dot, all is well: it&rsquo;s connected and just waiting for work.</p>
<h3 id="step-5-create-the-first-workflow">Step 5: Create the first workflow</h3>
<p>Workflows live in the repository under <code>.forgejo/workflows/</code>. In any repo, create the file <code>.forgejo/workflows/ci.yml</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">CI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">test</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">docker</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;Commit $GITHUB_SHA is being tested&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">node --version</span></span></span></code></pre></div>
</div>
<p>Broken down:</p>
<ul>
<li><strong><code>on: [push]</code></strong> – the workflow starts on every push.</li>
<li><strong><code>runs-on: docker</code></strong> – selects our runner via the label <code>docker</code>.</li>
<li><strong><code>actions/checkout@v7</code></strong> – checks out the code (the same action as with GitHub; Forgejo loads it automatically from its action registry).</li>
<li>The two <code>run</code> steps output the commit and the Node version – a minimal but real example you later replace with your actual build/test commands.</li>
</ul>
<p>Commit and push the file. The push triggers the workflow immediately.</p>
<h3 id="step-6-look-at-the-run">Step 6: Look at the run</h3>
<p>In the repository, open the <strong>Actions</strong> tab. Your run appears there – after a few seconds with a <strong>green checkmark</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-actions-runner/forgejo-actions-run_hu_c59ccf6e83194fd3.webp" srcset="/en/tutorials/forgejo-actions-runner/forgejo-actions-run_hu_aeb5301f72a74d69.webp 480w, /en/tutorials/forgejo-actions-runner/forgejo-actions-run_hu_c59ccf6e83194fd3.webp 768w, /en/tutorials/forgejo-actions-runner/forgejo-actions-run_hu_7e3903ab209bab10.webp 1200w, /en/tutorials/forgejo-actions-runner/forgejo-actions-run_hu_319c70e47173acdb.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-actions-runner/forgejo-actions-run_hu_81b91826cfb848f4.webp"
    alt="The Actions tab of a repository with a successfully completed CI workflow." title="The Actions tab: the workflow run is green."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The Actions tab: the workflow run is green.</figcaption></figure></p>
<p>A click on the run opens the <strong>job view</strong> with the individual steps and their logs. Here you see how <code>actions/checkout</code> clones the repository and the commands run one after another:</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-actions-runner/forgejo-job-log_hu_fdc2b5fdaf8b0b03.webp" srcset="/en/tutorials/forgejo-actions-runner/forgejo-job-log_hu_643f44265e9cd48c.webp 480w, /en/tutorials/forgejo-actions-runner/forgejo-job-log_hu_fdc2b5fdaf8b0b03.webp 768w, /en/tutorials/forgejo-actions-runner/forgejo-job-log_hu_4e9aa86a36390fc.webp 1200w, /en/tutorials/forgejo-actions-runner/forgejo-job-log_hu_1fdacd423d6b963c.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-actions-runner/forgejo-job-log_hu_19664e304ebff5ed.webp"
    alt="The detail view of a Forgejo Actions job with expanded step logs." title="Job detail view: all steps green, with complete logs."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Job detail view: all steps green, with complete logs.</figcaption></figure></p>
<p>The <code>node --version</code> step outputs <code>v24.20.0</code> for us – the proof that the job really ran in the <code>node:24-bookworm</code> container. With that your CI/CD is in place: from now on you can test, build and deploy whatever you need in the <code>run</code> steps.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The runner restarts and reports &ldquo;cannot ping the docker daemon … certificate is valid for docker,
…, not fjr-docker&rdquo;.</strong> The DinD service is named something other than <code>docker</code>, but its TLS
certificate is issued for <code>docker</code>. name the DinD service exactly <strong><code>docker</code></strong> (as above) and
address it via <code>DOCKER_HOST: tcp://docker:2376</code> – then the name matches the certificate.</p>
<p><strong>The job starts but fails at <code>actions/checkout</code> with a connection error.</strong> The runner was
registered with an <strong>internal</strong> instance URL (<code>http://forgejo:3000</code>). The job containers in DinD
can&rsquo;t resolve this name. re-register with the <strong>public</strong> URL <code>https://YOUR_DOMAIN</code> (delete the
<code>.runner</code> file in the volume first, or recreate the volume).</p>
<p><strong>The runner doesn&rsquo;t appear in the overview at all / the registration fails.</strong> Wrong or already-used
token, or the runner can&rsquo;t reach Forgejo. get a fresh token (step 1) and check that the runner
container reaches <code>https://YOUR_DOMAIN</code> (<code>docker compose run --rm runner wget -qO- https://YOUR_DOMAIN/api/healthz</code>).</p>
<p><strong>A job stays &ldquo;pending&rdquo; forever.</strong> No runner has a matching <strong>label</strong>. The workflow uses <code>runs-on: docker</code>, so the runner must carry the label <code>docker</code>. check the labels when registering; the runner
overview shows the labels per runner.</p>
<p><strong><code>actions/checkout</code> can&rsquo;t find the action.</strong> Forgejo loads actions from a configured registry (by
default <code>data.forgejo.org</code>). If the server is completely cut off from the internet, that fails.
allow outbound HTTPS access or mirror actions in an internal registry.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Updates.</strong> You update the runner and DinD like any Compose stack:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/forgejo-runner
</span></span><span class="line"><span class="cl">docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Keep the runner <strong>roughly on par with your Forgejo version</strong> – a heavily outdated runner version can run into problems with new Forgejo features. Pin a specific version instead of <code>latest</code> as above so updates happen deliberately.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Moving from runner v12 to v13
  </p>
  <div class="prose-kitchen text-sm">Runner <strong>v13</strong> comes with deliberate breaking changes: the workflow commands <code>set-output</code>, <code>set-env</code> and <code>add-path</code> have been removed without replacement – write to the files <code>$FORGEJO_OUTPUT</code>, <code>$FORGEJO_ENV</code> and <code>$FORGEJO_PATH</code> instead. On top of that, faulty expressions now make a job fail hard (instead of just warning), and in the runner configuration <code>container.network_mode</code> is now called <code>container.network</code>. A freshly set up runner like the one here isn&rsquo;t affected; if you bring existing workflows along, read the <a href="https://forgejo.org/2026-08-runner-release-v13/">v13 release notes</a> first.</div>
</div>
<p><strong>Backups.</strong> What&rsquo;s worth backing up is above all the <strong><code>.runner</code> file</strong> in the <code>runner_data</code> volume – it contains the registration. If it&rsquo;s lost, the runner registers as a <strong>new</strong> runner on the next start (the old one stays as &ldquo;offline&rdquo; in the overview and can be deleted there). A total loss is no drama: you get a new token and register again. The DinD data (<code>dind_certs</code>, job caches) is ephemeral and does <strong>not</strong> need to be backed up.</p>
<p><strong>Cleanup.</strong> The CI jobs create unused images and layers in DinD over time. Clean them up occasionally so the disk doesn&rsquo;t fill up:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose <span class="nb">exec</span> docker docker system prune -af</span></span></code></pre></div>
</div>
<p><strong>Security.</strong> The DinD runs <code>privileged</code> – treat the runner host like a security-critical system: only necessary ports open, no other sensitive services alongside, and CI only for repositories whose workflows you control. Whoever allows workflows from foreign forks should engage intensively with their risks beforehand.</p>
]]></content:encoded></item></channel></rss>