<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Iuri Covaliov</title>
    <description>The latest articles on DEV Community by Iuri Covaliov (@iuri_covaliov).</description>
    <link>https://dev.to/iuri_covaliov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3756977%2F197fbec2-f459-4ff4-8099-985651e0289b.jpg</url>
      <title>DEV Community: Iuri Covaliov</title>
      <link>https://dev.to/iuri_covaliov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iuri_covaliov"/>
    <language>en</language>
    <item>
      <title>GitLab CI Caching Didn’t Speed Up My Pipeline — Here’s Why</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Thu, 19 Mar 2026 14:33:36 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/gitlab-ci-caching-didnt-speed-up-my-pipeline-heres-why-21o3</link>
      <guid>https://dev.to/iuri_covaliov/gitlab-ci-caching-didnt-speed-up-my-pipeline-heres-why-21o3</guid>
      <description>&lt;p&gt;Most DevOps guides say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Enable caching — it will speed up your CI pipelines.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ve done that many times in my career. Here I'd like to share with you some of my thoughts on the topic illustrating it with a little experiment.&lt;/p&gt;

&lt;p&gt;I built a small GitLab CI lab, added dependency caching. Are you expecting faster runs?&lt;/p&gt;

&lt;p&gt;The result might surprise you:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My pipeline didn’t get faster at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In fact, in some cases, it was slightly slower.&lt;/p&gt;

&lt;p&gt;Before jumping to conclusions — this is not a post against caching.&lt;/p&gt;

&lt;p&gt;Caching worked exactly as expected.&lt;br&gt;
It just didn’t translate into faster pipeline duration in this particular setup.&lt;/p&gt;

&lt;p&gt;And that’s the part worth understanding.&lt;/p&gt;

&lt;p&gt;This article is not about &lt;em&gt;how to enable caching&lt;/em&gt;.&lt;br&gt;
It’s about what actually happens &lt;strong&gt;after you enable it&lt;/strong&gt; — and why the outcome might not match expectations.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I Wanted to Test
&lt;/h2&gt;

&lt;p&gt;I wanted to validate a simple assumption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does dependency caching really reduce pipeline duration?&lt;/li&gt;
&lt;li&gt;Where does the improvement come from?&lt;/li&gt;
&lt;li&gt;When is caching actually worth it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built a small Python project with a multi-stage GitLab CI pipeline and measured the results.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;The pipeline has three stages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prepare → install dependencies&lt;/li&gt;
&lt;li&gt;quality → compile/lint&lt;/li&gt;
&lt;li&gt;test → run tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each job installs dependencies independently — just like in many real-world pipelines.&lt;/p&gt;

&lt;p&gt;To make the effect visible, I used slightly heavier dependencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pandas&lt;/li&gt;
&lt;li&gt;scipy&lt;/li&gt;
&lt;li&gt;scikit-learn&lt;/li&gt;
&lt;li&gt;matplotlib&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Baseline: No Cache
&lt;/h2&gt;

&lt;p&gt;Each job runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;time &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dependencies are downloaded in every job&lt;/li&gt;
&lt;li&gt;work is repeated across stages&lt;/li&gt;
&lt;li&gt;every pipeline run starts from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;td&gt;~38s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;td&gt;~34s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Adding Cache
&lt;/h2&gt;

&lt;p&gt;I introduced GitLab cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;.cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;requirements.txt&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.cache/pip&lt;/span&gt;
    &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull-push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And configured pip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;PIP_CACHE_DIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$CI_PROJECT_DIR/.cache/pip"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now dependencies should be reused between jobs and runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No cache&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~38s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No cache&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;~34s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With cache&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~40s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With cache&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;~38s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Almost no difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Didn’t It Get Faster?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Fast package source
&lt;/h3&gt;

&lt;p&gt;If your runner uses a nearby mirror (for example, Hetzner), downloads are already fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. pip is efficient
&lt;/h3&gt;

&lt;p&gt;Modern Python packaging uses prebuilt wheels, making installs quick.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cache has overhead
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;archive creation&lt;/li&gt;
&lt;li&gt;upload/download&lt;/li&gt;
&lt;li&gt;extraction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This overhead can cancel the benefit.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CI jobs spend time elsewhere
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;container startup&lt;/li&gt;
&lt;li&gt;image pulling&lt;/li&gt;
&lt;li&gt;repo checkout&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Real Takeaway
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Dependency caching is not automatically a performance optimization.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Its impact depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dependency size&lt;/li&gt;
&lt;li&gt;network conditions&lt;/li&gt;
&lt;li&gt;runner configuration&lt;/li&gt;
&lt;li&gt;pipeline structure&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When Caching Helps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;large dependency trees&lt;/li&gt;
&lt;li&gt;slow networks&lt;/li&gt;
&lt;li&gt;distributed runners&lt;/li&gt;
&lt;li&gt;frequent pipeline runs&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When It Might Not Help
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;small projects&lt;/li&gt;
&lt;li&gt;fast mirrors&lt;/li&gt;
&lt;li&gt;short pipelines&lt;/li&gt;
&lt;li&gt;high cache overhead&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Not Just About Speed
&lt;/h2&gt;

&lt;p&gt;Caching can still:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reduce outbound traffic&lt;/li&gt;
&lt;li&gt;improve resilience&lt;/li&gt;
&lt;li&gt;reduce dependency on external registries&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What’s Next
&lt;/h2&gt;

&lt;p&gt;Next step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;testing shared cache with S3-compatible storage&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;You can find the full lab here:&lt;br&gt;
👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabCIPipelinesWithDependencyCaching" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabCIPipelinesWithDependencyCaching&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Not every best practice gives a measurable improvement — but understanding why is where real DevOps begins.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce"&gt;GitLab Behind Cloudflare Zero Trust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/gitlab-behind-cloudflare-tunnel-removing-inbound-ssh-exposure-217m"&gt;GitLab Behind Cloudflare Tunnel --- Removing Inbound SSH Exposure&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/gitlab-se-behind-cloudflare-zero-trust-part-3-private-ci-runner-4a6b"&gt;GitLab SE behind Cloudflare Zero Trust: Part 3. Private CI Runner&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>gitlab</category>
      <category>cicd</category>
      <category>python</category>
    </item>
    <item>
      <title>GitLab SE behind Cloudflare Zero Trust: Private CI Runner</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Thu, 12 Mar 2026 18:08:13 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/gitlab-se-behind-cloudflare-zero-trust-part-3-private-ci-runner-4a6b</link>
      <guid>https://dev.to/iuri_covaliov/gitlab-se-behind-cloudflare-zero-trust-part-3-private-ci-runner-4a6b</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the previous labs, we deployed a self‑hosted GitLab instance behind Cloudflare Zero Trust to protect access to the web interface and repositories. That setup works well for human users, but CI systems introduce a different kind of traffic.&lt;/p&gt;

&lt;p&gt;GitLab runners interact with GitLab non‑interactively: they fetch repositories, call APIs, and upload job results automatically. When the public GitLab domain is protected by Cloudflare Access, these interactions can be redirected to authentication flows that CI jobs cannot complete.&lt;/p&gt;

&lt;p&gt;This lab explores a practical solution: running CI jobs on a &lt;strong&gt;GitLab Runner inside the private network&lt;/strong&gt;, allowing CI traffic to bypass the Zero Trust authentication layer entirely.&lt;/p&gt;

&lt;p&gt;The core idea is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Humans access GitLab through Cloudflare Zero Trust. CI jobs interact with GitLab entirely inside the private network.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;Extend the existing GitLab deployment by introducing a &lt;strong&gt;private GitLab Runner VM&lt;/strong&gt; that executes CI jobs without relying on the public Cloudflare‑protected endpoint.&lt;/p&gt;

&lt;p&gt;By the end of this lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  GitLab remains accessible through Cloudflare Zero Trust&lt;/li&gt;
&lt;li&gt;  CI jobs run on a &lt;strong&gt;runner VM in the same private network&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  repository checkout uses an &lt;strong&gt;internal GitLab endpoint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  pipelines run successfully without hitting Cloudflare authentication&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Constraints / assumptions
&lt;/h2&gt;

&lt;p&gt;This lab intentionally keeps the environment simple and reproducible.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  GitLab is already running behind &lt;strong&gt;Cloudflare Zero Trust&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  GitLab and the runner VM share the same &lt;strong&gt;private network&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  infrastructure is provisioned using &lt;strong&gt;Vagrant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  the runner uses the &lt;strong&gt;Docker executor&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  the goal is architecture clarity, not production‑grade scaling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The environment is intentionally local. The focus of the lab is &lt;strong&gt;network design and CI architecture&lt;/strong&gt;, not cloud infrastructure configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  High‑level design
&lt;/h2&gt;

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

&lt;p&gt;The architecture introduces two separate access paths to GitLab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Human access
&lt;/h3&gt;

&lt;p&gt;Developers reach GitLab through the public domain protected by Cloudflare Zero Trust.&lt;/p&gt;

&lt;p&gt;Developer → Cloudflare Zero Trust → GitLab public domain&lt;/p&gt;

&lt;h3&gt;
  
  
  CI execution
&lt;/h3&gt;

&lt;p&gt;GitLab communicates with the runner entirely inside the private network.&lt;/p&gt;

&lt;p&gt;GitLab VM → internal NGINX listener → GitLab Runner VM&lt;/p&gt;

&lt;p&gt;The runner registers using the internal endpoint and uses the same address for repository checkout through the &lt;code&gt;clone_url&lt;/code&gt; setting.&lt;/p&gt;

&lt;p&gt;This ensures CI jobs never follow the public access path and therefore never encounter Cloudflare authentication redirects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1 --- Make it work
&lt;/h2&gt;

&lt;p&gt;The first phase focuses on simply getting a pipeline to run on the private runner.&lt;/p&gt;

&lt;p&gt;Two architectural adjustments are required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expose an internal GitLab entrypoint
&lt;/h3&gt;

&lt;p&gt;The previous lab disabled GitLab's bundled NGINX because external traffic was handled by the Cloudflare tunnel and reverse proxy.&lt;/p&gt;

&lt;p&gt;However, CI jobs require a stable HTTP endpoint that supports Git operations. Using Puma directly is unreliable for this purpose, and the public domain leads to Cloudflare authentication redirects.&lt;/p&gt;

&lt;p&gt;To solve this, the GitLab VM exposes an internal NGINX listener bound to the private IP.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;p&gt;http://&lt;code&gt;&amp;lt;GITLAB_PRIVATE_IP&amp;gt;&lt;/code&gt;{=html}:8081&lt;/p&gt;

&lt;p&gt;This endpoint is used for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  runner registration&lt;/li&gt;
&lt;li&gt;  GitLab API calls&lt;/li&gt;
&lt;li&gt;  repository checkout&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Provision a runner VM
&lt;/h3&gt;

&lt;p&gt;A second VM is created inside the same private network and configured with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Docker&lt;/li&gt;
&lt;li&gt;  GitLab Runner&lt;/li&gt;
&lt;li&gt;  a Docker executor&lt;/li&gt;
&lt;li&gt;  explicit runner tags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During registration, the runner configuration sets:&lt;/p&gt;

&lt;p&gt;url → internal GitLab endpoint&lt;br&gt;
clone_url → internal GitLab endpoint&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;clone_url&lt;/code&gt; parameter ensures repository checkout uses the internal address instead of the public GitLab domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validate with a smoke pipeline
&lt;/h3&gt;

&lt;p&gt;A minimal CI project confirms that the runner works correctly by verifying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  runner identity&lt;/li&gt;
&lt;li&gt;  container execution&lt;/li&gt;
&lt;li&gt;  GitLab reachability&lt;/li&gt;
&lt;li&gt;  successful repository checkout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the pipeline succeeds, the private CI path is confirmed to work correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2 --- Reduce trust / Harden access
&lt;/h2&gt;

&lt;p&gt;Once the runner works, the next step is reducing permissions that are not actually required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable privileged Docker mode
&lt;/h3&gt;

&lt;p&gt;Privileged containers grant very broad capabilities to CI jobs, including access to host‑level resources.&lt;/p&gt;

&lt;p&gt;The smoke pipeline only runs basic commands inside a container, so privileged mode is unnecessary.&lt;/p&gt;

&lt;p&gt;Disabling it reduces the attack surface of the runner host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restrict job scheduling with tags
&lt;/h3&gt;

&lt;p&gt;The runner is configured with explicit tags and &lt;strong&gt;untagged jobs are disabled&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Pipelines must explicitly reference these tags to run on the runner.&lt;/p&gt;

&lt;p&gt;This prevents unrelated projects or misconfigured jobs from executing there accidentally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;Running GitLab behind Cloudflare Zero Trust introduces an important architectural distinction.&lt;/p&gt;

&lt;p&gt;Interactive users and automated CI jobs have very different requirements.&lt;/p&gt;

&lt;p&gt;Human users can complete authentication flows through Cloudflare Access. CI systems cannot.&lt;/p&gt;

&lt;p&gt;Instead of weakening the Zero Trust configuration, a better approach is introducing a &lt;strong&gt;dedicated internal access path for CI systems&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This lab demonstrates that pattern clearly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  public access remains protected&lt;/li&gt;
&lt;li&gt;  CI traffic stays inside the private network&lt;/li&gt;
&lt;li&gt;  repository checkout uses a stable internal endpoint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Another key lesson is that CI runners should start permissive enough to work, but once pipelines are validated, unnecessary privileges should be removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;This lab opens the door to several useful extensions.&lt;/p&gt;

&lt;p&gt;Possible follow‑up experiments include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;GitLab behind Cloudflare --- Runner VM (multiple runners and
scheduling policies)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GitLab behind Cloudflare --- S3 storage VM&lt;/strong&gt; for caches,
artifacts, and pages&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GitLab behind Cloudflare --- Automation with Ansible&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GitLab behind Cloudflare --- Full CI/CD example&lt;/strong&gt;\
(build → checks → unit tests → reports → packaging →
containerization → deployment)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these steps moves the lab environment closer to a realistic CI/CD platform while keeping the architecture transparent and reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;The full lab --- including Vagrant configuration, provisioning scripts, diagrams, and the reproducible runbook --- is available in the repository linked below.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSEBehindCloudflare03Runner" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSEBehindCloudflare03Runner&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Published Labs in This Series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/securing-a-remote-linux-host-with-firewalld-and-openvpn-291g"&gt;Securing a Remote Linux Host with firewalld and OpenVPN&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce"&gt;GitLab Behind Cloudflare Zero Trust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/gitlab-behind-cloudflare-tunnel-removing-inbound-ssh-exposure-217m"&gt;GitLab Behind Cloudflare Tunnel --- Removing Inbound SSH Exposure&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each lab explores a boundary in infrastructure design and gradually shifts trust from network assumptions toward identity and workload isolation.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ci</category>
      <category>gitlab</category>
      <category>network</category>
    </item>
    <item>
      <title>GitLab Behind Cloudflare Tunnel --- Removing Inbound SSH Exposure</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Wed, 04 Mar 2026 14:02:20 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/gitlab-behind-cloudflare-tunnel-removing-inbound-ssh-exposure-217m</link>
      <guid>https://dev.to/iuri_covaliov/gitlab-behind-cloudflare-tunnel-removing-inbound-ssh-exposure-217m</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce"&gt;previous lab&lt;/a&gt;, GitLab was placed behind Cloudflare Access and&lt;br&gt;
protected by identity. HTTPS traffic passed through Nginx, and access to&lt;br&gt;
the web interface required authentication at the edge.&lt;/p&gt;

&lt;p&gt;But one part of the system still relied on a traditional assumption: SSH&lt;br&gt;
was reachable via an open port.&lt;/p&gt;

&lt;p&gt;This lab continues the evolution. The goal is not to add another&lt;br&gt;
security layer for its own sake, but to change the exposure model&lt;br&gt;
itself. Instead of accepting inbound SSH connections, the host&lt;br&gt;
establishes an outbound tunnel to Cloudflare. Both HTTPS and SSH access&lt;br&gt;
are then mediated through identity.&lt;/p&gt;

&lt;p&gt;The trust boundary moves from ports to people.&lt;/p&gt;




&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;Rework a self-hosted GitLab setup so that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  HTTPS traffic flows through Cloudflare Tunnel.&lt;/li&gt;
&lt;li&gt;  SSH access is gated by Cloudflare Access.&lt;/li&gt;
&lt;li&gt;  No direct inbound SSH exposure is required.&lt;/li&gt;
&lt;li&gt;  The GitLab VM remains disposable and isolated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not about eliminating SSH. It is about changing who gets to&lt;br&gt;
initiate it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Constraints / Assumptions
&lt;/h2&gt;

&lt;p&gt;The environment remains intentionally simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  GitLab CE runs inside a VM.&lt;/li&gt;
&lt;li&gt;  The host runs Nginx.&lt;/li&gt;
&lt;li&gt;  The domain is managed in Cloudflare.&lt;/li&gt;
&lt;li&gt;  Cloudflare Access is already configured (from the previous lab).&lt;/li&gt;
&lt;li&gt;  Firewall rules are left unchanged.&lt;/li&gt;
&lt;li&gt;  Only free-tier Cloudflare features are used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lab focuses on architecture and exposure patterns, not&lt;br&gt;
cloud-provider specifics.&lt;/p&gt;




&lt;h2&gt;
  
  
  High-Level Design
&lt;/h2&gt;

&lt;p&gt;In the original setup, identity protected HTTP, but SSH still depended&lt;br&gt;
on a reachable port:&lt;/p&gt;

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

&lt;p&gt;User&lt;br&gt;
→ Cloudflare (DNS + Access)&lt;br&gt;
→ Host Nginx (443)&lt;br&gt;
→ GitLab VM&lt;/p&gt;

&lt;p&gt;SSH:&lt;br&gt;
User → Host 22 → VM 22&lt;/p&gt;

&lt;p&gt;Identity was enforced for the web interface, but the transport layer&lt;br&gt;
still trusted network reachability.&lt;/p&gt;

&lt;p&gt;After introducing Cloudflare Tunnel, the model shifts:&lt;/p&gt;

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

&lt;p&gt;User&lt;br&gt;
→ Cloudflare Access&lt;br&gt;
→ Cloudflare Tunnel (outbound from host)&lt;br&gt;
→ Nginx (HTTP)&lt;br&gt;
→ GitLab VM&lt;/p&gt;

&lt;p&gt;SSH:&lt;br&gt;
User → Cloudflare Access&lt;br&gt;
→ Cloudflare Tunnel&lt;br&gt;
→ VM (SSH)&lt;/p&gt;

&lt;p&gt;The host now initiates and maintains the connection to Cloudflare.&lt;br&gt;
Nothing waits for unsolicited inbound traffic. Access is mediated by&lt;br&gt;
identity first, transport second.&lt;/p&gt;

&lt;p&gt;The firewall is no longer the primary boundary. It becomes a fallback.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1 --- Make It Work
&lt;/h2&gt;

&lt;p&gt;The first objective is straightforward: establish functional&lt;br&gt;
connectivity through the tunnel.&lt;/p&gt;

&lt;p&gt;Cloudflared is installed on the host and authenticated. A named tunnel&lt;br&gt;
is created and bound to the domain. DNS records are routed through the&lt;br&gt;
tunnel. Ingress rules are defined so that the main domain forwards to&lt;br&gt;
Nginx locally, and a dedicated SSH subdomain forwards to the VM's SSH&lt;br&gt;
port.&lt;/p&gt;

&lt;p&gt;Once the tunnel runs as a systemd service, the web interface loads&lt;br&gt;
through Cloudflare exactly as before. The difference is subtle but&lt;br&gt;
important: the traffic now enters through an outbound tunnel rather than&lt;br&gt;
a directly reachable service.&lt;/p&gt;

&lt;p&gt;SSH follows the same pattern. The client uses:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ProxyCommand cloudflared access ssh --hostname ssh-&amp;lt;domain&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Before any SSH handshake reaches the VM, Cloudflare Access enforces&lt;br&gt;
authentication in the browser and issues a short-lived token. Only then&lt;br&gt;
does the SSH session proceed.&lt;/p&gt;

&lt;p&gt;From the user's perspective, nothing changes.\&lt;br&gt;
From the architecture's perspective, everything does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 --- Reduce Trust / Harden Access
&lt;/h2&gt;

&lt;p&gt;With the system working, the focus shifts from connectivity to trust&lt;br&gt;
boundaries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Identity Before Transport
&lt;/h3&gt;

&lt;p&gt;Previously, SSH relied on an open port. Now, it depends on identity&lt;br&gt;
validation at the edge. If authentication fails, transport never begins.&lt;/p&gt;

&lt;p&gt;This is a meaningful shift. The system no longer assumes that network&lt;br&gt;
reachability implies legitimacy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outbound-Only Connectivity
&lt;/h3&gt;

&lt;p&gt;The host no longer listens passively for SSH. Instead, it maintains an&lt;br&gt;
outbound connection to Cloudflare. Even if inbound port 22 remains open&lt;br&gt;
for the sake of the lab, the architecture no longer depends on it.&lt;/p&gt;

&lt;p&gt;The design supports full inbound closure without structural change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disposable Infrastructure
&lt;/h3&gt;

&lt;p&gt;Recreating the VM regenerates SSH host keys. Clients detect this change&lt;br&gt;
and require reconciliation via &lt;code&gt;known_hosts&lt;/code&gt;. This is not an&lt;br&gt;
inconvenience; it is a reminder that trust in SSH is explicit and&lt;br&gt;
stateful.&lt;/p&gt;

&lt;p&gt;Disposable infrastructure surfaces operational truths that static&lt;br&gt;
systems tend to hide.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;The most important shift in this lab is conceptual.&lt;/p&gt;

&lt;p&gt;In the first iteration, identity wrapped around services that were still&lt;br&gt;
network-exposed. In this one, identity becomes the gateway to transport&lt;br&gt;
itself.&lt;/p&gt;

&lt;p&gt;When access decisions happen before packets reach the host, the&lt;br&gt;
firewall's role changes. It is no longer the primary defense but a&lt;br&gt;
secondary containment layer.&lt;/p&gt;

&lt;p&gt;The tunnel model also simplifies reasoning about exposure. The host&lt;br&gt;
initiates connectivity; it does not advertise it. That inversion removes&lt;br&gt;
an entire class of assumptions about scanning, probing, and open ports.&lt;/p&gt;

&lt;p&gt;Finally, rebuilding the VM highlights something often overlooked:&lt;br&gt;
security models must account for operational behavior. SSH host key&lt;br&gt;
changes are not edge cases --- they are part of lifecycle management. A&lt;br&gt;
secure design must remain understandable and manageable under change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;A natural extension of this lab is introducing private GitLab Runner&lt;br&gt;
VMs.&lt;/p&gt;

&lt;p&gt;These runners would:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Exist only inside the private network.&lt;/li&gt;
&lt;li&gt;  Register with GitLab over internal addresses.&lt;/li&gt;
&lt;li&gt;  Execute CI jobs without any public exposure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This would extend the pattern beyond secure access and into secure&lt;br&gt;
execution. Identity gates entry; isolation protects workload processing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;The full lab --- including runbook, configuration examples, and tunnel&lt;br&gt;
setup --- is available here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSEBehindCloudflare02Tunnels" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSEBehindCloudflare02Tunnels&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Published Labs in This Series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/securing-a-remote-linux-host-with-firewalld-and-openvpn-291g"&gt;Securing a Remote Linux Host with firewalld and OpenVPN&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce"&gt;GitLab Behind Cloudflare Zero Trust&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each lab explores a boundary in infrastructure design and gradually&lt;br&gt;
shifts trust from network assumptions toward identity and workload&lt;br&gt;
isolation.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cloudflare</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Owning Your GitHub Actions CI: Moving to Self-Hosted Runners</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Thu, 26 Feb 2026 09:39:18 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/owning-your-github-actions-ci-moving-to-self-hosted-runners-5ee0</link>
      <guid>https://dev.to/iuri_covaliov/owning-your-github-actions-ci-moving-to-self-hosted-runners-5ee0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Moving to self-hosted runners isn’t just about saving CI minutes.&lt;br&gt;
It’s about shifting from using infrastructure to operating it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a long time, GitHub-hosted runners were simply the default. You push code. A workflow runs somewhere. Artifacts appear. Clean. Abstracted. Convenient.&lt;/p&gt;

&lt;p&gt;I had experimented with self-hosted runners before — mostly while learning GitHub Actions. It worked, but I didn’t see a compelling reason to keep it.&lt;/p&gt;

&lt;p&gt;Then two things changed.&lt;/p&gt;

&lt;p&gt;First, I once had to build a macOS executable for a small utility. The binary produced by the default runner didn’t work on two developers’ laptops. We ended up building it directly on one of their machines. That was the first time I clearly felt that CI environments are not neutral. They are specific systems with specific assumptions.&lt;/p&gt;

&lt;p&gt;Second — more recently — GitHub runner minutes stopped being effectively free.&lt;/p&gt;

&lt;p&gt;That’s when experimentation turned into architecture.&lt;/p&gt;




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




&lt;h2&gt;
  
  
  Not Just Installing a Runner
&lt;/h2&gt;

&lt;p&gt;Installing a runner is easy.&lt;/p&gt;

&lt;p&gt;Designing a small, intentional self-hosted CI layout is different.&lt;/p&gt;

&lt;p&gt;I structured the lab around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Linux server (Ubuntu 24.04 in my case)&lt;/li&gt;
&lt;li&gt;Separate runners for:

&lt;ul&gt;
&lt;li&gt;Personal repositories&lt;/li&gt;
&lt;li&gt;Organization repositories&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;A clear directory structure:
&lt;/li&gt;

&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/opt/gh-actions-runners/
  personal-runner-1/
  organization-runner-1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Explicit labels for routing jobs&lt;/li&gt;
&lt;li&gt;Docker-based build workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal wasn’t to recreate GitHub’s infrastructure.&lt;/p&gt;

&lt;p&gt;It was to remove invisible layers and understand the system I was relying on.&lt;/p&gt;

&lt;p&gt;That difference matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reality Check #1: Organizational Boundaries Are Part of Runtime
&lt;/h2&gt;

&lt;p&gt;At one point everything looked correct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runner online&lt;/li&gt;
&lt;li&gt;Labels matching&lt;/li&gt;
&lt;li&gt;Workflow targeting &lt;code&gt;self-hosted, Linux, X64, ci&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yet the job remained stuck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Waiting for a runner to pick up this job...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No obvious error. No crash. Just silence.&lt;/p&gt;

&lt;p&gt;The issue wasn’t YAML.&lt;br&gt;
It wasn’t the runner service.&lt;/p&gt;

&lt;p&gt;It was a runner group setting: public repositories were not allowed to use that runner.&lt;/p&gt;

&lt;p&gt;Once enabled, the job started immediately.&lt;/p&gt;

&lt;p&gt;It was a small issue — but revealing.&lt;/p&gt;

&lt;p&gt;Self-hosted CI expands the surface area of configuration. Policy, permissions, and organizational settings become part of runtime behavior.&lt;/p&gt;

&lt;p&gt;When you host it yourself, abstraction doesn’t disappear. It just moves.&lt;/p&gt;


&lt;h2&gt;
  
  
  Reality Check #2: Docker Isn’t Just Docker
&lt;/h2&gt;

&lt;p&gt;In another project outside the lab, I ran a simple workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker build&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker push&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing complex.&lt;/p&gt;

&lt;p&gt;Everything in the runner workspace looked correct.&lt;br&gt;
&lt;code&gt;ls&lt;/code&gt; showed the Dockerfile.&lt;br&gt;
Permissions were fine.&lt;br&gt;
The runner service was healthy.&lt;/p&gt;

&lt;p&gt;And still, builds failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lstat /var/lib/snapd/void/... no such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem wasn’t the workflow.&lt;/p&gt;

&lt;p&gt;Docker had been installed via Snap.&lt;br&gt;
The runner workspace lived under &lt;code&gt;/opt&lt;/code&gt;.&lt;br&gt;
Snap confinement prevented Docker from accessing that path.&lt;/p&gt;

&lt;p&gt;From the shell, everything looked fine.&lt;br&gt;
From Docker’s perspective, the directory simply did not exist.&lt;/p&gt;

&lt;p&gt;The fix was straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove Snap Docker&lt;/li&gt;
&lt;li&gt;Install Docker via apt&lt;/li&gt;
&lt;li&gt;Restart the runner service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the lesson wasn’t about Docker.&lt;/p&gt;

&lt;p&gt;It was about environmental assumptions.&lt;/p&gt;

&lt;p&gt;When you move to self-hosted CI, packaging decisions become architectural decisions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reality Check #3: Ephemeral Is a Luxury
&lt;/h2&gt;

&lt;p&gt;GitHub-hosted runners are ephemeral.&lt;br&gt;
They clean themselves after every job.&lt;/p&gt;

&lt;p&gt;Self-hosted runners do not.&lt;/p&gt;

&lt;p&gt;After a handful of Docker builds, disk usage starts to grow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Images&lt;/li&gt;
&lt;li&gt;Layers&lt;/li&gt;
&lt;li&gt;Build cache&lt;/li&gt;
&lt;li&gt;Stopped containers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing breaks immediately.&lt;br&gt;
But entropy accumulates quietly.&lt;/p&gt;

&lt;p&gt;So the lab gained maintenance scripts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A recommended cleanup routine&lt;/li&gt;
&lt;li&gt;An aggressive recovery option&lt;/li&gt;
&lt;li&gt;Optional cron automation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Owning CI means owning lifecycle — not just pipelines.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Changes
&lt;/h2&gt;

&lt;p&gt;Moving to self-hosted runners isn’t primarily about saving money.&lt;/p&gt;

&lt;p&gt;It’s about shifting responsibility.&lt;/p&gt;

&lt;p&gt;You gain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Predictable environments&lt;/li&gt;
&lt;li&gt;Full control over toolchain versions&lt;/li&gt;
&lt;li&gt;No external minute limits&lt;/li&gt;
&lt;li&gt;Transparency in how jobs are executed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You also gain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Infrastructure maintenance&lt;/li&gt;
&lt;li&gt;Disk management&lt;/li&gt;
&lt;li&gt;Docker lifecycle awareness&lt;/li&gt;
&lt;li&gt;Organizational configuration complexity&lt;/li&gt;
&lt;li&gt;Security considerations (your runner executes arbitrary code)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s not better or worse.&lt;/p&gt;

&lt;p&gt;It’s simply more explicit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lab Repository
&lt;/h2&gt;

&lt;p&gt;The full lab setup — including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runner installation runbook&lt;/li&gt;
&lt;li&gt;Architecture notes&lt;/li&gt;
&lt;li&gt;Example workflows&lt;/li&gt;
&lt;li&gt;Docker cleanup scripts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;is available in the accompanying repository:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/GitHubSelfHostedRunners" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/GitHubSelfHostedRunners&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;There’s a difference between using CI and operating CI.&lt;/p&gt;

&lt;p&gt;Even at small scale, that difference is meaningful.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>cicd</category>
      <category>docker</category>
    </item>
    <item>
      <title>Replacing Static AWS Credentials in CI/CD with GitHub OIDC (A Practical DevOps Lab)</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Wed, 18 Feb 2026 15:30:49 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/replacing-static-aws-credentials-in-cicd-with-github-oidc-a-practical-devops-lab-2222</link>
      <guid>https://dev.to/iuri_covaliov/replacing-static-aws-credentials-in-cicd-with-github-oidc-a-practical-devops-lab-2222</guid>
      <description>&lt;p&gt;Storing long‑lived AWS access keys inside CI/CD pipelines is common. It&lt;br&gt;
works. It is simple. And it usually stays that way for years.&lt;/p&gt;

&lt;p&gt;But static credentials expand the trust boundary more than we often&lt;br&gt;
realize. They live in repository settings, they require manual rotation,&lt;br&gt;
and if exposed, they remain valid until explicitly revoked.&lt;/p&gt;

&lt;p&gt;In this lab, I built a minimal deployment pipeline from GitHub Actions&lt;br&gt;
to Amazon S3 and then deliberately refactored it. The goal was not to&lt;br&gt;
deploy a website. The goal was to observe how the trust model changes&lt;br&gt;
when static credentials are replaced with short‑lived role assumption.&lt;/p&gt;

&lt;p&gt;The structure is simple: the deployment remains the same, the trust boundary changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;Demonstrate a practical migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Phase 1 --- deploy to S3 using static AWS credentials stored in
GitHub Secrets&lt;/li&gt;
&lt;li&gt;  Phase 2 --- remove static credentials and use GitHub OIDC (OpenID Connect) federation
with IAM role assumption&lt;/li&gt;
&lt;li&gt;  Keep the deployment behavior identical while improving the
authentication model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The target (S3 static site) is intentionally minimal so that&lt;br&gt;
authentication and identity flow stay in focus.&lt;/p&gt;




&lt;h2&gt;
  
  
  Constraints / Assumptions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Single AWS account&lt;/li&gt;
&lt;li&gt;  Manual setup (no Terraform for clarity)&lt;/li&gt;
&lt;li&gt;  S3 static website enabled (lab shortcut)&lt;/li&gt;
&lt;li&gt;  GitHub Actions used as CI/CD engine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Public S3 access is used only to simplify validation. In a production&lt;br&gt;
setup, CloudFront and stricter bucket policies would replace it.&lt;/p&gt;




&lt;h2&gt;
  
  
  High-Level Design
&lt;/h2&gt;

&lt;p&gt;The central question of this lab:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can we change how CI/CD authenticates to AWS without changing how it&lt;br&gt;
deploys?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Phase 1 --- Make it work
&lt;/h3&gt;

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

&lt;p&gt;Architecture:&lt;/p&gt;

&lt;p&gt;GitHub Actions → GitHub Secrets → IAM User → S3&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  An IAM user is created.&lt;/li&gt;
&lt;li&gt;  Access keys are generated.&lt;/li&gt;
&lt;li&gt;  Keys are stored as repository secrets.&lt;/li&gt;
&lt;li&gt;  The workflow uploads files using &lt;code&gt;aws s3 sync&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system works exactly as expected.&lt;/p&gt;

&lt;p&gt;However:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Credentials are long‑lived.&lt;/li&gt;
&lt;li&gt;  Rotation is manual.&lt;/li&gt;
&lt;li&gt;  Any secret leakage provides direct AWS access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing is broken --- but trust is broad.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 --- Reduce Trust / Harden Access
&lt;/h2&gt;

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

&lt;p&gt;In the second phase, the IAM user is removed from the deployment path.&lt;/p&gt;

&lt;p&gt;Architecture becomes:&lt;/p&gt;

&lt;p&gt;GitHub Actions → OIDC Token → IAM OIDC Provider → Security Token Service (STS) → IAM Role → S3&lt;/p&gt;

&lt;p&gt;Three architectural changes occur.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Introduce an IAM OIDC Provider
&lt;/h3&gt;

&lt;p&gt;AWS is configured to trust tokens issued by:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://token.actions.githubusercontent.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This creates a formal trust anchor between GitHub and AWS.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Define a Scoped Trust Policy
&lt;/h3&gt;

&lt;p&gt;The IAM role trust policy restricts access to:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repo:&amp;lt;OWNER&amp;gt;/&amp;lt;REPO&amp;gt;:ref:refs/heads/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Only this repository&lt;/li&gt;
&lt;li&gt;  Only this branch&lt;/li&gt;
&lt;li&gt;  Only through OIDC&lt;/li&gt;
&lt;li&gt;  Only via STS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trust becomes explicit and narrow.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Replace Static Credentials with Role Assumption
&lt;/h3&gt;

&lt;p&gt;The GitHub workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Requests an ID token&lt;/li&gt;
&lt;li&gt;  Calls &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  Receives short‑lived credentials&lt;/li&gt;
&lt;li&gt;  Deploys using the same &lt;code&gt;aws s3 sync&lt;/code&gt; command&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deployment logic remains unchanged.&lt;/p&gt;

&lt;p&gt;But no static AWS keys exist in GitHub anymore.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Changed
&lt;/h2&gt;

&lt;p&gt;Operationally, nothing changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Same repository&lt;/li&gt;
&lt;li&gt;  Same workflow structure&lt;/li&gt;
&lt;li&gt;  Same deployment command&lt;/li&gt;
&lt;li&gt;  Same S3 bucket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a trust perspective, everything changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Credentials are now short‑lived&lt;/li&gt;
&lt;li&gt;  No manual rotation is required&lt;/li&gt;
&lt;li&gt;  Access is scoped to one repository and branch&lt;/li&gt;
&lt;li&gt;  AWS access depends on a validated identity token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system now grants access based on verified identity rather than&lt;br&gt;
stored secrets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observations from Implementation
&lt;/h2&gt;

&lt;p&gt;A few practical lessons emerged during the lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Incorrect S3 ARNs in role policies immediately cause &lt;code&gt;AccessDenied&lt;/code&gt;
errors.&lt;/li&gt;
&lt;li&gt;  Missing &lt;code&gt;id-token: write&lt;/code&gt; permission prevents OIDC role assumption.&lt;/li&gt;
&lt;li&gt;  Trust policy &lt;code&gt;sub&lt;/code&gt; mismatches block access cleanly and predictably.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; is the most useful debugging command
in this flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Federated identity setups are less forgiving than static keys --- but&lt;br&gt;
that strictness is the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; Replacing static credentials does not require changing deployment
logic.&lt;/li&gt;
&lt;li&gt; Trust policy design is the most sensitive part of the setup.&lt;/li&gt;
&lt;li&gt; Short‑lived credentials reduce operational overhead and risk.&lt;/li&gt;
&lt;li&gt; CI/CD hardening is primarily about narrowing trust boundaries.&lt;/li&gt;
&lt;li&gt; Security improvements can be incremental and observable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lab reinforces a simple pattern: &lt;strong&gt;Make it work. Then deliberately reduce trust.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;This setup can be extended in several directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Build and push Docker images to ECR using the same OIDC role\&lt;/li&gt;
&lt;li&gt;  Deploy to ECS instead of S3\&lt;/li&gt;
&lt;li&gt;  Provision IAM and OIDC provider via Terraform\&lt;/li&gt;
&lt;li&gt;  Add GitHub Environments and environment‑scoped trust conditions\&lt;/li&gt;
&lt;li&gt;  Remove public S3 access and introduce CloudFront&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each extension builds on the same identity model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;Full reproducible runbook and workflows:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/GitHubActionswithAWSOIDC" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/GitHubActionswithAWSOIDC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This repository is intended to be read alongside the article: the article explains why each layer exists, while the repository shows how it is implemented.&lt;/p&gt;




</description>
      <category>oidc</category>
      <category>githubactions</category>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>Securing a Remote Linux Host with firewalld and OpenVPN</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Fri, 13 Feb 2026 21:42:48 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/securing-a-remote-linux-host-with-firewalld-and-openvpn-291g</link>
      <guid>https://dev.to/iuri_covaliov/securing-a-remote-linux-host-with-firewalld-and-openvpn-291g</guid>
      <description>&lt;p&gt;For my experiments I'm renting a remote Linux server. As soon as it was&lt;br&gt;
online, it became clear that the first real problem wasn't installing&lt;br&gt;
software, but reducing how much of the server was exposed to the&lt;br&gt;
internet by default. Services like SSH are continuously scanned and&lt;br&gt;
probed, and a freshly provisioned host is immediately visible.&lt;/p&gt;

&lt;p&gt;This lab documents how I secured that host step by step: first by&lt;br&gt;
establishing a strict firewall baseline, then by introducing a private&lt;br&gt;
administrative VPN, and finally by removing public SSH exposure&lt;br&gt;
entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;The goal of this lab is to secure a rented Linux host that acts as the&lt;br&gt;
entry point to a private network.&lt;/p&gt;

&lt;p&gt;In this setup, the public host fronts several internal virtual machines.&lt;br&gt;
External HTTP(S) traffic is terminated on the host and routed to&lt;br&gt;
internal services via a reverse proxy, while internal systems are not&lt;br&gt;
directly exposed to the internet.&lt;/p&gt;

&lt;p&gt;Although built as a homelab, this mirrors real-world infrastructure&lt;br&gt;
patterns where a single edge node provides controlled access to private&lt;br&gt;
services.&lt;/p&gt;

&lt;p&gt;This lab focuses on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Reducing the exposed attack surface&lt;/li&gt;
&lt;li&gt;  Replacing unrestricted public SSH with controlled access&lt;/li&gt;
&lt;li&gt;  Establishing a reusable hardening pattern for future hosts&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Constraints / Assumptions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  The host is publicly reachable.&lt;/li&gt;
&lt;li&gt;  SSH is initially exposed.&lt;/li&gt;
&lt;li&gt;  Out-of-band recovery access is available from the provider.&lt;/li&gt;
&lt;li&gt;  A non-root admin user with sudo is used.&lt;/li&gt;
&lt;li&gt;  Temporary SSH disruption is acceptable during hardening.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  High-Level Design
&lt;/h2&gt;

&lt;p&gt;The host serves two roles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Public entry point (reverse proxy, VPN)&lt;/li&gt;
&lt;li&gt; Gateway to a private internal network&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Traffic model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Internet → Public host (only required ports open)&lt;/li&gt;
&lt;li&gt;  Public host → Private VMs&lt;/li&gt;
&lt;li&gt;  Administrative access via VPN only (Phase 2)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final state minimizes public exposure and separates management&lt;br&gt;
traffic from application traffic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1 --- Make it work
&lt;/h2&gt;

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

&lt;p&gt;The objective is to regain control.&lt;/p&gt;

&lt;p&gt;Using firewalld with a deny-by-default policy, inbound traffic is&lt;br&gt;
restricted to explicitly allowed services. SSH remains publicly&lt;br&gt;
accessible temporarily to avoid lockout during baseline setup.&lt;/p&gt;

&lt;p&gt;Phase 1 ensures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Explicitly defined inbound access&lt;/li&gt;
&lt;li&gt;  Persistent firewall rules&lt;/li&gt;
&lt;li&gt;  Stable administrative connectivity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ICMP remains enabled for diagnostics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 --- Reduce trust / Harden access
&lt;/h2&gt;

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

&lt;p&gt;With a stable firewall in place, administrative access is redesigned.&lt;/p&gt;

&lt;h3&gt;
  
  
  Introduce a Private Administrative Network
&lt;/h3&gt;

&lt;p&gt;OpenVPN is deployed to create a private management plane. SSH is no&lt;br&gt;
longer treated as a public service but as a capability granted to&lt;br&gt;
authenticated VPN members.&lt;/p&gt;

&lt;p&gt;Split-tunnel mode is intentionally used. Administrative traffic routes&lt;br&gt;
through the VPN, while general internet traffic remains local.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restrict SSH to VPN + Controlled IP
&lt;/h3&gt;

&lt;p&gt;Once VPN access is validated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Public SSH is removed.&lt;/li&gt;
&lt;li&gt;  SSH is allowed only from:

&lt;ul&gt;
&lt;li&gt;  The VPN subnet&lt;/li&gt;
&lt;li&gt;  A specific home IP address (fallback)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;This eliminates global SSH exposure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable Root SSH Login
&lt;/h3&gt;

&lt;p&gt;Root login is disabled. Administrative access occurs through a dedicated&lt;br&gt;
non-root user with sudo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Automating Client Profile Generation
&lt;/h2&gt;

&lt;p&gt;Instead of manually assembling the &lt;code&gt;.ovpn&lt;/code&gt; file, the repository includes&lt;br&gt;
a helper script:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;make-ovpn.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Reads the server configuration&lt;/li&gt;
&lt;li&gt;  Embeds the correct CA and tls-crypt key&lt;/li&gt;
&lt;li&gt;  Extracts the correct certificate block&lt;/li&gt;
&lt;li&gt;  Generates a ready-to-import profile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./make-ovpn.sh client1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This prevents formatting mistakes and ensures server/client consistency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Validation Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  SSH not publicly reachable&lt;/li&gt;
&lt;li&gt;  SSH reachable via VPN&lt;/li&gt;
&lt;li&gt;  Root login disabled&lt;/li&gt;
&lt;li&gt;  VPN stable after reboot&lt;/li&gt;
&lt;li&gt;  Firewall rules persist&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Firewall first, VPN second.&lt;/li&gt;
&lt;li&gt;  Separate access plane from data plane.&lt;/li&gt;
&lt;li&gt;  tls-crypt keys must match exactly.&lt;/li&gt;
&lt;li&gt;  Split-tunnel DNS requires careful handling.&lt;/li&gt;
&lt;li&gt;  Root access should be deliberate.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Replace OpenVPN with WireGuard&lt;/li&gt;
&lt;li&gt;  Mirror firewall rules at provider level&lt;/li&gt;
&lt;li&gt;  Convert the host into a bastion/jump host pattern&lt;/li&gt;
&lt;li&gt;  Automate via Ansible&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;The full and step‑by‑step documentation — is available here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/ProtectRemoteHostWithFirewallAndVPN" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/ProtectRemoteHostWithFirewallAndVPN&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This repository is intended to be read alongside the article: the article explains why each layer exists, while the repository shows how it is implemented.&lt;/p&gt;




&lt;h2&gt;
  
  
  Published Labs in This Series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce"&gt;GitLab Behind Cloudflare Zero Trust&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>firewalld</category>
      <category>openvpn</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Self-hosting GitLab Behind Cloudflare Zero Trust (A Practical DevOps Lab)</title>
      <dc:creator>Iuri Covaliov</dc:creator>
      <pubDate>Fri, 06 Feb 2026 16:31:33 +0000</pubDate>
      <link>https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce</link>
      <guid>https://dev.to/iuri_covaliov/self-hosting-gitlab-behind-cloudflare-zero-trust-a-practical-devops-lab-18ce</guid>
      <description>&lt;p&gt;The best way to understand infrastructure is to &lt;strong&gt;build it incrementally&lt;/strong&gt;, observing how each decision shapes security, operability, and trust.&lt;/p&gt;

&lt;p&gt;This project started as a simple question: &lt;em&gt;How can I expose a self-hosted GitLab instance safely without jumping straight into enterprise tooling?&lt;/em&gt; The answer turned into a small but realistic DevOps lab.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;The goal was to deploy &lt;strong&gt;GitLab Community Edition&lt;/strong&gt; in a way that mirrors real-world infrastructure decisions, while remaining safe, observable, and reversible.&lt;/p&gt;

&lt;p&gt;This lab focuses on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hosting GitLab on a private virtual machine&lt;/li&gt;
&lt;li&gt;exposing it to the internet in controlled stages&lt;/li&gt;
&lt;li&gt;progressively reducing implicit trust&lt;/li&gt;
&lt;li&gt;using only free or community-tier tooling&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Scenario
&lt;/h3&gt;

&lt;p&gt;Hosting GitLab yourself is not difficult.&lt;/p&gt;

&lt;p&gt;Hosting it &lt;strong&gt;responsibly&lt;/strong&gt; is.&lt;/p&gt;

&lt;p&gt;Exposing GitLab directly to the internet invites scanners, brute-force attempts, and unnecessary risk. Instead of starting with a fully locked-down design, this lab explores how to expose GitLab &lt;em&gt;progressively&lt;/em&gt;, adding security layers only when they are understood and justified.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Initial Idea
&lt;/h2&gt;

&lt;p&gt;Instead of exposing GitLab directly to the internet, the system is built in layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitLab lives in a private VM&lt;/li&gt;
&lt;li&gt;A reverse proxy handles public traffic&lt;/li&gt;
&lt;li&gt;Cloudflare eventually becomes the gatekeeper&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each layer adds responsibility — and reduces risk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1 — Make It Work
&lt;/h2&gt;

&lt;p&gt;At first, the goal is simple: &lt;em&gt;make GitLab reachable&lt;/em&gt;.&lt;/p&gt;

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

&lt;p&gt;GitLab runs inside a VM with no public IP. The host server owns the public address and forwards requests using Nginx.&lt;/p&gt;

&lt;p&gt;This already provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;isolation&lt;/li&gt;
&lt;li&gt;easy rebuilds&lt;/li&gt;
&lt;li&gt;clean separation of concerns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But it is still &lt;strong&gt;trust-based&lt;/strong&gt; access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 — Stop Trusting the Network
&lt;/h2&gt;

&lt;p&gt;Once GitLab works, security becomes the focus.&lt;/p&gt;

&lt;p&gt;Instead of asking &lt;em&gt;"Is this request coming from the right IP?"&lt;/em&gt;, the system asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Who is this user, and should they be here?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Cloudflare Zero Trust sits in front of GitLab and enforces identity-based access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Even though this is a lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the architecture mirrors real enterprise setups&lt;/li&gt;
&lt;li&gt;Zero Trust concepts are applied correctly&lt;/li&gt;
&lt;li&gt;no infrastructure redesign was required mid-way&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most importantly, the system evolved &lt;strong&gt;incrementally&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reverse proxies are foundational&lt;/li&gt;
&lt;li&gt;Identity beats IP-based security&lt;/li&gt;
&lt;li&gt;Free tiers are enough to learn real concepts&lt;/li&gt;
&lt;li&gt;Phased designs reduce mistakes&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;This lab can be extended in many directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare Tunnel instead of exposed ports&lt;/li&gt;
&lt;li&gt;HTTPS-only Git operations&lt;/li&gt;
&lt;li&gt;GitLab runners&lt;/li&gt;
&lt;li&gt;Infrastructure as Code&lt;/li&gt;
&lt;li&gt;Migration to KVM or Proxmox&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;p&gt;The full lab — including Vagrant configuration, provisioning scripts, and step‑by‑step documentation — is available here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSE-behind-CloudFlare" rel="noopener noreferrer"&gt;https://github.com/ic-devops-lab/devops-labs/tree/main/GitLabSE-behind-CloudFlare&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This repository is intended to be read alongside the article: the article explains &lt;em&gt;why&lt;/em&gt; each layer exists, while the repository shows &lt;em&gt;how&lt;/em&gt; it is implemented.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;This project demonstrates that Zero Trust is not a product — it is a &lt;strong&gt;design approach&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;By starting simple and layering security intentionally, even a small home lab can teach patterns that scale to real-world systems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Published Labs in This Series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/securing-a-remote-linux-host-with-firewalld-and-openvpn-291g"&gt;Securing a Remote Linux Host with firewalld and OpenVPN&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://dev.to/iuri_covaliov/gitlab-behind-cloudflare-tunnel-removing-inbound-ssh-exposure-217m"&gt;GitLab Behind Cloudflare Tunnel --- Removing Inbound SSH Exposure&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each lab explores a boundary in infrastructure design and gradually&lt;br&gt;
shifts trust from network assumptions toward identity and workload&lt;br&gt;
isolation.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>gitlab</category>
      <category>cloudflare</category>
      <category>security</category>
    </item>
  </channel>
</rss>
