<?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>Forgejo – Serverküche</title><link>https://serverkueche.de/en/tags/forgejo/</link><description>Forgejo – 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/forgejo/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><item><title>Forgejo: your own Git server behind Traefik</title><link>https://serverkueche.de/en/tutorials/forgejo-git-server/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/forgejo-git-server/</guid><description>Set up Forgejo with Docker &amp; Traefik: your own Git server with HTTPS, repos via web UI, cloning over HTTPS and SSH – the self-hosted GitHub alternative.</description><content:encoded><![CDATA[<p>GitHub is convenient – but your code then lives on someone else&rsquo;s servers. With <strong>Forgejo</strong> you host your repositories yourself: a lean, completely open-source Git platform that runs on the smallest VPS and still brings issues, pull requests, wiki and CI.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Forgejo 16.0.3</strong> runs as a single container behind your Traefik, reachable at <code>https://YOUR_DOMAIN</code> with an automatic Let&rsquo;s Encrypt certificate. You create repositories via the web interface and clone/push them either <strong>over HTTPS</strong> or <strong>over SSH</strong>. As the database we use <strong>SQLite</strong> – for a personal or small-team Git server that&rsquo;s easily enough and saves an additional database container. Forgejo is the community fork of Gitea and clearly on the rise in the self-hosting scene.</p>
<p>Why self-host at all? Your code, your issues and your project history then live exclusively on <strong>your</strong> server – no dependence on the terms, rate limits or acquisitions of an external provider, and full data sovereignty. Forgejo is no stripped-down toy: it can do almost everything you know from GitHub (see step 7), but stays lean enough for a small VPS.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server with <strong>Debian 13</strong> and running Docker (tested on a netcup VPS).</li>
<li>A <strong><a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a></strong> (the <code>proxy</code> network and the resolver <code>le</code> from it are assumed) – Forgejo brings no own HTTPS, Traefik handles the encryption.</li>
<li>A <strong>(sub)domain</strong> that points to your server via an A/AAAA record (<code>YOUR_DOMAIN</code>).</li>
<li>For real backups: <strong><a href="/en/tutorials/restic-backups/">encrypted backups with Restic</a></strong>.</li>
</ul>
<p>Forgejo is frugal and runs even on the smallest vServer. How much server your planned setup needs in total 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-git-server/" data-content-piece="VPS 1000 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 1000 G12</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Forgejo with SQLite runs comfortably on the VPS 1000.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 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-create-the-compose-file">Step 1: Create the Compose file</h3>
<p>Create a folder for the stack 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 <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/forgejo</span></span></code></pre></div>
</div>
<p>Create the file <code>compose.yaml</code>. Replace <code>YOUR_DOMAIN</code> with your real domain:</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">forgejo</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">codeberg.org/forgejo/forgejo:16.0.3</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">forgejo</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">USER_UID</span><span class="p">:</span><span class="w"> </span><span class="m">1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">USER_GID</span><span class="p">:</span><span class="w"> </span><span class="m">1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__server__DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__server__ROOT_URL</span><span class="p">:</span><span class="w"> </span><span class="l">https://YOUR_DOMAIN/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__server__SSH_DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__server__START_SSH_SERVER</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__server__SSH_PORT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2222&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__server__SSH_LISTEN_PORT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2222&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__service__DISABLE_REGISTRATION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">FORGEJO__database__DB_TYPE</span><span class="p">:</span><span class="w"> </span><span class="l">sqlite3</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">forgejo_data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/etc/timezone:/etc/timezone:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/etc/localtime:/etc/localtime:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;2222:2222&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</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 class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;wget&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-q&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-O&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/dev/null&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost:3000/api/healthz&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_interval</span><span class="p">:</span><span class="w"> </span><span class="l">2s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.forgejo.rule=Host(`YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.forgejo.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.forgejo.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.forgejo.loadbalancer.server.port=3000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">proxy]</span><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">forgejo_data</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>The most important points in detail:</p>
<ul>
<li><strong><code>FORGEJO__…</code> variables</strong> configure Forgejo directly via environment variables (section and key via double underscore). <code>ROOT_URL</code> must be exactly the public HTTPS address, otherwise clone links and redirects point nowhere.</li>
<li><strong>Git over SSH</strong> is handled by Forgejo&rsquo;s <strong>built-in SSH server</strong> (<code>START_SSH_SERVER=true</code>) – so you don&rsquo;t have to touch your host&rsquo;s hardened SSH access (port 22). It listens on <strong>2222</strong> in the container and is published to the same host port.</li>
<li><strong><code>loadbalancer.server.port=3000</code></strong> tells Traefik the web interface runs internally on port 3000. Only SSH (2222) is published directly as a port – web access goes exclusively via Traefik.</li>
<li>The <strong>healthcheck</strong> with <strong><code>start_interval: 2s</code></strong> is deliberately set that way (more on that in &ldquo;When things go wrong&rdquo;).</li>
<li><strong><code>DISABLE_REGISTRATION: &quot;true&quot;</code></strong> closes open self-registration from the start – your Git server is thus not open to strangers. You still create your <strong>admin account</strong> perfectly normally in the initial install wizard (step 3); alternatively you could generate it automatically via <code>FORGEJO__admin__*</code> variables.</li>
</ul>
<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>SSH port: definitely set both values
  </p>
  <div class="prose-kitchen text-sm"><code>SSH_PORT</code> (the port number shown in the clone link) <strong>and</strong> <code>SSH_LISTEN_PORT</code> (the port the server actually listens on) must match. If you set only <code>SSH_PORT</code> and leave out <code>SSH_LISTEN_PORT</code>, Forgejo tries to listen on the old default port and crashes at startup with <code>bind: address already in use</code> in a restart loop.</div>
</div>
<h3 id="step-2-start-and-wait-for-healthy">Step 2: Start and wait for &ldquo;healthy&rdquo;</h3>
<p>Start the 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 compose up -d</span></span></code></pre></div>
</div>
<p>Check the status:</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>After a few seconds the container is <code>healthy</code>:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME      IMAGE                                 SERVICE   STATUS                    PORTS
</span></span><span class="line"><span class="cl">forgejo   codeberg.org/forgejo/forgejo:16.0.3   forgejo   Up 12 seconds (healthy)   22/tcp, 3000/tcp, 0.0.0.0:2222-&gt;2222/tcp, [::]:2222-&gt;2222/tcp</span></span></code></pre></div>
</div>
<p>The <code>22/tcp</code> in the port list is only a port declared by the image, not a published one – the only port reachable from outside is the 2222 you mapped yourself.</p>
<p>Traefik now fetches the certificate in the background. Check from your machine that the web interface responds over HTTPS:</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">curl -s https://YOUR_DOMAIN/api/healthz</span></span></code></pre></div>
</div>
<p>Expected output – Forgejo reports itself healthy. As long as the initial install (step 3) isn&rsquo;t finished, the endpoint answers briefly with status and slogan:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{
</span></span><span class="line"><span class="cl">  &#34;status&#34;: &#34;pass&#34;,
</span></span><span class="line"><span class="cl">  &#34;description&#34;: &#34;Forgejo: Beyond coding. We forge.&#34;
</span></span><span class="line"><span class="cl">}</span></span></code></pre></div>
</div>
<p>Once the wizard is done, the same URL additionally lists the individual checks <code>cache:ping</code> and <code>database:ping</code> – and shows your instance name instead of the slogan.</p>
<h3 id="step-3-initial-install--admin-account">Step 3: Initial install &amp; admin account</h3>
<p>Open <code>https://YOUR_DOMAIN</code> in the browser. On the first start, Forgejo shows the <strong>initial install</strong>. The database and server settings are already correctly pre-filled thanks to the environment variables – you only have to create the <strong>administrator account</strong> here (expand the corresponding section and enter a username, email and a strong password). A click on <strong>Install Forgejo</strong>, and after a brief restart you land on your empty dashboard:</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-git-server/forgejo-dashboard_hu_61545266f48bd407.webp" srcset="/en/tutorials/forgejo-git-server/forgejo-dashboard_hu_6320e3496316b9ad.webp 480w, /en/tutorials/forgejo-git-server/forgejo-dashboard_hu_61545266f48bd407.webp 768w, /en/tutorials/forgejo-git-server/forgejo-dashboard_hu_daa5dccabd5ca7ac.webp 1200w, /en/tutorials/forgejo-git-server/forgejo-dashboard_hu_8f76629141bdd973.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-git-server/forgejo-dashboard_hu_f9283f6aed07e8ec.webp"
    alt="Forgejo dashboard right after the initial setup – still without repositories." title="The dashboard after the first login."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard after the first login.</figcaption></figure></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 install page stays open until the wizard has been completed once – so finish it <strong>right after <code>docker compose up -d</code></strong> and create your admin account in the process. Open self-registration is already disabled via <code>FORGEJO__service__DISABLE_REGISTRATION: &quot;true&quot;</code> in the Compose, so no one can create an account from outside.</div>
</div>
<h3 id="step-4-create-the-first-repository">Step 4: Create the first repository</h3>
<p>Click the <strong>+</strong> at the top right and then <strong>New Repository</strong>. Assign a name and check <strong>Initialize repository</strong> (creates a <code>README</code> right away so the repo isn&rsquo;t empty):</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-git-server/forgejo-repo-anlegen_hu_12da8fc6c5a7f951.webp" srcset="/en/tutorials/forgejo-git-server/forgejo-repo-anlegen_hu_113cc065af230282.webp 480w, /en/tutorials/forgejo-git-server/forgejo-repo-anlegen_hu_12da8fc6c5a7f951.webp 768w, /en/tutorials/forgejo-git-server/forgejo-repo-anlegen_hu_8d21835a02d8b9d1.webp 1200w, /en/tutorials/forgejo-git-server/forgejo-repo-anlegen_hu_a6931b20498f6b83.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-git-server/forgejo-repo-anlegen_hu_3c33d7e7a900dcee.webp"
    alt="The &ldquo;New Repository&rdquo; form in Forgejo with a name field and initialization option." title="Create a new repository."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Create a new repository.</figcaption></figure></p>
<p>After creating it, you see the repository view. Via the <strong>HTTPS / SSH</strong> toggle you get the matching clone address:</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-git-server/forgejo-repository_hu_8505c999f33cde90.webp" srcset="/en/tutorials/forgejo-git-server/forgejo-repository_hu_29ee2437e4f37723.webp 480w, /en/tutorials/forgejo-git-server/forgejo-repository_hu_8505c999f33cde90.webp 768w, /en/tutorials/forgejo-git-server/forgejo-repository_hu_48bd6aeb47f9832e.webp 1200w, /en/tutorials/forgejo-git-server/forgejo-repository_hu_e93682661bc6332f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-git-server/forgejo-repository_hu_856d0fe22716eeb2.webp"
    alt="Repository view in Forgejo with a README and the HTTPS/SSH toggle for the clone URL." title="The clone URL is available for either HTTPS or SSH."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The clone URL is available for either HTTPS or SSH.</figcaption></figure></p>
<h3 id="step-5-clone-and-push-over-https">Step 5: Clone and push over HTTPS</h3>
<p>The fastest way is over HTTPS – it works immediately, without setting up keys:</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">git clone https://YOUR_DOMAIN/YOUR_USER/my-first-repo.git</span></span></code></pre></div>
</div>
<p>A <strong>public</strong> repository clones without any login – expected output:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Cloning into &#39;my-first-repo&#39;...
</span></span><span class="line"><span class="cl">remote: Enumerating objects: 3, done.
</span></span><span class="line"><span class="cl">remote: Counting objects: 100% (3/3), done.
</span></span><span class="line"><span class="cl">remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
</span></span><span class="line"><span class="cl">Receiving objects: 100% (3/3), done.</span></span></code></pre></div>
</div>
<p>For a private repository, Git asks for a username and password right here. That closes the loop – you create a file, commit it and push it back to your server:</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> my-first-repo
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;My first note&#34;</span> &gt;&gt; notes.txt
</span></span><span class="line"><span class="cl">git add notes.txt
</span></span><span class="line"><span class="cl">git commit -m <span class="s2">&#34;First note&#34;</span>
</span></span><span class="line"><span class="cl">git push</span></span></code></pre></div>
</div>
<p>At the latest on <code>git push</code>, Forgejo wants to know who you are: Git asks for a username and password. At the end, Git confirms the new state on the server:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">To https://YOUR_DOMAIN/YOUR_USER/my-first-repo.git
</span></span><span class="line"><span class="cl">   f398f79..7bad57b  main -&gt; main</span></span></code></pre></div>
</div>
<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">On a fresh client, the first commit fails with <code>Please tell me who you are</code> as long as Git doesn&rsquo;t know your name and email. Set them once globally: <code>git config --global user.name &quot;YOUR NAME&quot;</code> and <code>git config --global user.email &quot;YOUR_EMAIL&quot;</code>.</div>
</div>
<p>Seconds later the commit appears in the web interface – your code now lives versioned on your own server.</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">For HTTPS, use an <strong>access token</strong> (under <strong>Settings → Applications</strong>) instead of your password. It can be revoked individually and is mandatory once you enable two-factor auth for your account.</div>
</div>
<h3 id="step-6-clone-and-push-over-ssh">Step 6: Clone and push over SSH</h3>
<p>For daily use, SSH is more convenient (no password per push). For that, store your <strong>public</strong> SSH key under <strong>Settings → SSH / GPG keys → Add key</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/forgejo-git-server/forgejo-ssh-key_hu_79b231c9ee254ef8.webp" srcset="/en/tutorials/forgejo-git-server/forgejo-ssh-key_hu_c650c2c66f4f52dc.webp 480w, /en/tutorials/forgejo-git-server/forgejo-ssh-key_hu_79b231c9ee254ef8.webp 768w, /en/tutorials/forgejo-git-server/forgejo-ssh-key_hu_aa7e2af8e7734959.webp 1200w, /en/tutorials/forgejo-git-server/forgejo-ssh-key_hu_b8c26aaed3a380a.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/forgejo-git-server/forgejo-ssh-key_hu_1b89bdf5f36f7a52.webp"
    alt="Forgejo settings page &ldquo;Manage SSH keys&rdquo; with the &ldquo;Add key&rdquo; button." title="Store the public SSH key in your account."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Store the public SSH key in your account.</figcaption></figure></p>
<p>You display your public key locally with <code>cat ~/.ssh/id_ed25519.pub</code> (if none exists yet: <code>ssh-keygen -t ed25519</code>). Then clone over SSH – note <strong>port 2222</strong>:</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">git clone ssh://git@YOUR_DOMAIN:2222/YOUR_USER/my-first-repo.git</span></span></code></pre></div>
</div>
<p>For that to work, the SSH port must be <strong>open in both firewalls</strong> – in the <a href="/en/tutorials/firewall-ufw-setup/">UFW firewall</a> (<code>ufw allow 2222/tcp</code>) and, if used, in the <a href="/en/tutorials/netcup-firewall-setup/">netcup firewall</a>.</p>
<h3 id="step-7-more-than-just-git-hosting">Step 7: More than just Git hosting</h3>
<p>A repository and cloning are just the beginning – Forgejo brings the complete workbench around it. You find these features in the navigation bar of every repository or your account:</p>
<ul>
<li><strong>Issues &amp; pull requests:</strong> a full-featured ticket system and code review including labels, milestones and assignments – the basis for team collaboration.</li>
<li><strong>Forgejo Actions:</strong> a CI/CD engine <strong>compatible with GitHub Actions</strong>. Existing <code>.github/workflows</code> files often run unchanged. Actions do, however, need a separate <strong>runner</strong> that executes the jobs – for security reasons it doesn&rsquo;t belong on the production host and has its own tutorial: <a href="/en/tutorials/forgejo-actions-runner/">Forgejo Actions: your own CI/CD runner with Docker</a> (makes the case for dedicated cores: a good reason for a root server, see <a href="/en/netcup-recommendation/">netcup recommendation</a>).</li>
<li><strong>Wiki &amp; releases:</strong> documentation right at the project and versioned release downloads.</li>
<li><strong>Package registry:</strong> Forgejo can host container images, npm, Maven, PyPI and other packages – handy if you want to store your own artifacts without running another service.</li>
<li><strong>Organizations &amp; teams:</strong> bundle repositories and control access rights per team.</li>
</ul>
<p>You also don&rsquo;t have to migrate your projects by hand: via <strong>+ → Migration</strong>, Forgejo imports an existing repository from GitHub, GitLab or another Forgejo/Gitea instance – including issues, pull requests and releases, not just the Git history. This way you switch from GitHub to your own server in a few minutes without losing anything.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The container restarts repeatedly (<code>Restarting</code>), the log says <code>bind: address already in use</code>.</strong>
The built-in SSH server collides with itself because <code>SSH_PORT</code> and <code>SSH_LISTEN_PORT</code> don&rsquo;t match. Set <strong>both</strong> to the same value (here <code>2222</code>) – then Forgejo starts cleanly.</p>
<p><strong>The container takes forever to become <code>healthy</code>.</strong> By default, Docker runs the first healthcheck only after the <code>interval</code> (30 s) – so the container looks &ldquo;unhealthy&rdquo; for 30 s+, even though Forgejo has long been ready in ~2 s. The solution is already in the Compose above: <strong><code>start_interval: 2s</code></strong> checks every 2 seconds during the startup phase and switches to <code>healthy</code> as soon as the app responds. (Requires Docker 25+ / Compose v2.20+ – given on Debian 13.)</p>
<p><strong>Traefik returns 502 Bad Gateway.</strong> Almost always the wrong port: Forgejo&rsquo;s web interface listens internally on <strong>3000</strong>, so <code>loadbalancer.server.port=3000</code> must be set and the container must be on the <code>proxy</code> network.</p>
<p><strong>Clone links show <code>localhost</code> or the wrong port.</strong> Then <code>ROOT_URL</code>, <code>SSH_DOMAIN</code> or <code>SSH_PORT</code> are wrong. Correct the values in the Compose and restart with <code>docker compose up -d</code>.</p>
<p><strong>SSH clone fails with <code>Permission denied (publickey)</code>.</strong> The SSH server is running, but your public key isn&rsquo;t stored in the account yet (step 6) – or you forgot the port <code>2222</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Everything lives in the <code>forgejo_data</code> volume</strong> (<code>/data</code>): the SQLite database, your repositories and the SSH server&rsquo;s host keys. This very volume is what should be backed up – cleanest with <a href="/en/tutorials/restic-backups/">Restic</a>. Because a SQLite file is written during operation, you back it up consistently by either briefly stopping the container (<code>docker compose stop</code>) or using Forgejo&rsquo;s built-in dump:</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> -u git forgejo forgejo dump -t /tmp -f /tmp/forgejo-dump.zip</span></span></code></pre></div>
</div>
<p><code>-u git</code> runs the dump as the <code>git</code> user instead of root (as root, Forgejo refuses the dump). To <code>/tmp</code> instead of <code>/data</code>, because the <code>git</code> account can&rsquo;t write in <code>/data</code> itself (the root directory <code>/data</code> belongs to root, only the subfolders like <code>/data/gitea</code> and <code>/data/git</code> belong to the <code>git</code> user) and the dump would otherwise write recursively into the packed data directory. You then fetch the finished zip out of the container – to where Restic backs it 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 cp forgejo:/tmp/forgejo-dump.zip ./forgejo-dump.zip</span></span></code></pre></div>
</div>
<p>The zip contains the database dump (<code>forgejo-db.sql</code>), the <code>app.ini</code> and all repository data – a complete, self-contained restore point.</p>
<p><strong>Updates:</strong> Forgejo releases new versions regularly (currently the 16 series). For an update, set the new tag in the <code>compose.yaml</code> (instead of <code>16.0.3</code>) and pull 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">docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Because the data lives in the volume, repos and accounts are preserved. Deliberately pin the version to a fixed tag instead of <code>latest</code> and take a look at the <a href="https://forgejo.org/releases/">release notes</a> <strong>before</strong> a major jump – any migration hints are there. Also make a fresh backup before every update: a Git server without a working backup is a concentrated risk for all your code.</p>
<p><strong>Securing it:</strong> A publicly reachable Git server is a worthwhile target. Three things you should do right away: <strong>disable open registration</strong> (see step 3), enable <strong>two-factor authentication</strong> for your admin account under <strong>Settings → Security</strong>, and use a normal account instead of the administrator for everyday work. Keep Forgejo up to date promptly – the fast releases also contain security fixes. And publish only the ports you really need: outward, 443 (Traefik) and your SSH Git port 2222 suffice.</p>
]]></content:encoded></item></channel></rss>