<?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: ohmylock</title>
    <description>The latest articles on DEV Community by ohmylock (@ohmylock).</description>
    <link>https://dev.to/ohmylock</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%2F3791357%2Fe0cc8ae8-4cc2-45d3-8c65-fbdb98c3a3b5.png</url>
      <title>DEV Community: ohmylock</title>
      <link>https://dev.to/ohmylock</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ohmylock"/>
    <language>en</language>
    <item>
      <title>I built glenv — a CLI to stop manually managing GitLab CI/CD variables</title>
      <dc:creator>ohmylock</dc:creator>
      <pubDate>Wed, 25 Feb 2026 09:20:34 +0000</pubDate>
      <link>https://dev.to/ohmylock/i-built-glenv-a-cli-to-stop-manually-managing-gitlab-cicd-variables-1beo</link>
      <guid>https://dev.to/ohmylock/i-built-glenv-a-cli-to-stop-manually-managing-gitlab-cicd-variables-1beo</guid>
      <description>&lt;h2&gt;
  
  
  The problem every GitLab user eventually hits
&lt;/h2&gt;

&lt;p&gt;You've got a &lt;code&gt;.env.production&lt;/code&gt; file with 80 variables. Time to deploy to a new environment. So you open GitLab, navigate to Settings → CI/CD → Variables, and start clicking.&lt;/p&gt;

&lt;p&gt;Add variable. Set key. Paste value. Toggle "masked". Toggle "protected". Save. Repeat.&lt;/p&gt;

&lt;p&gt;After the fifth variable you're already making mistakes. After the twentieth you've lost count. After the fiftieth you've decided that whoever designed this workflow has never had to actually use it.&lt;/p&gt;

&lt;p&gt;The next attempt is a bash script. Something like:&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="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; key value&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://gitlab.com/api/v4/projects/&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;/variables"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"PRIVATE-TOKEN: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--form&lt;/span&gt; &lt;span class="s2"&gt;"key=&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--form&lt;/span&gt; &lt;span class="s2"&gt;"value=&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; .env.production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works until it doesn't. No rate limiting, no retry on 429, no masked/protected flags, sequential execution, no way to preview what's changing. One mistyped variable and you're debugging a broken pipeline at 2am.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/ohmylock/glenv" rel="noopener noreferrer"&gt;glenv&lt;/a&gt; to fix this properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet glenv
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;glenv&lt;/strong&gt; is a single-binary CLI written in Go that syncs &lt;code&gt;.env&lt;/code&gt; files with GitLab CI/CD variables via the API. It handles bulk imports, exports, diffs, and multi-environment workflows — with rate limiting and auto-classification built in.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Syncs hundreds of variables in seconds with concurrent workers&lt;/li&gt;
&lt;li&gt;Auto-detects which variables should be &lt;code&gt;masked&lt;/code&gt;, &lt;code&gt;protected&lt;/code&gt;, or &lt;code&gt;file&lt;/code&gt; type&lt;/li&gt;
&lt;li&gt;Shows a diff before applying any changes so there are no surprises&lt;/li&gt;
&lt;li&gt;Handles GitLab's rate limits and 429 responses automatically&lt;/li&gt;
&lt;li&gt;Manages production, staging, and custom environments from a single config&lt;/li&gt;
&lt;li&gt;Works with gitlab.com and any self-hosted instance&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Get started in 30 seconds
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS/Linux via Homebrew&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ohmylock/tools/glenv

&lt;span class="c"&gt;# Or via go install&lt;/span&gt;
go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/ohmylock/glenv/cmd/glenv@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set your credentials:&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;export &lt;/span&gt;&lt;span class="nv"&gt;GITLAB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"glpat-xxxxxxxxxxxx"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITLAB_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"12345678"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preview what would change before touching anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;glenv diff &lt;span class="nt"&gt;-f&lt;/span&gt; .env.production &lt;span class="nt"&gt;-e&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+ DB_HOST=postgres.internal
+ DB_PORT=5432
~ API_KEY: *** → ***           [masked]
- OLD_DEPRECATED_VAR
= LOG_LEVEL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it looks right, apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;glenv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; .env.production &lt;span class="nt"&gt;-e&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. 80 variables, a few seconds, done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Smart variable classification
&lt;/h2&gt;

&lt;p&gt;One of the more annoying parts of the GitLab UI is that you have to manually decide whether each variable should be masked or protected. Miss a &lt;code&gt;DATABASE_PASSWORD&lt;/code&gt; and it shows up in plain text in your pipeline logs.&lt;/p&gt;

&lt;p&gt;glenv auto-classifies variables based on key name patterns and value properties:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;When applied&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;masked&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Key contains &lt;code&gt;_TOKEN&lt;/code&gt;, &lt;code&gt;SECRET&lt;/code&gt;, &lt;code&gt;PASSWORD&lt;/code&gt;, &lt;code&gt;API_KEY&lt;/code&gt;, &lt;code&gt;DSN&lt;/code&gt; — and value is single-line, ≥8 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;protected&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Environment is &lt;code&gt;production&lt;/code&gt; AND key matches a secret pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;file&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Key contains &lt;code&gt;PRIVATE_KEY&lt;/code&gt;, &lt;code&gt;_CERT&lt;/code&gt;, &lt;code&gt;_PEM&lt;/code&gt; — or value contains &lt;code&gt;-----BEGIN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Variables with placeholder values like &lt;code&gt;your_api_key_here&lt;/code&gt; or &lt;code&gt;CHANGE_ME&lt;/code&gt; are automatically skipped — they won't pollute your remote variables.&lt;/p&gt;

&lt;p&gt;You can customize the patterns via config:&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;classify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;masked_patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_TOKEN"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SECRET"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASSWORD"&lt;/span&gt;
  &lt;span class="na"&gt;masked_exclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MAX_TOKENS"&lt;/span&gt;   &lt;span class="c1"&gt;# don't mask rate limit settings&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PORT"&lt;/span&gt;
  &lt;span class="na"&gt;file_patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRIVATE_KEY"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_PEM"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Multi-environment workflows
&lt;/h2&gt;

&lt;p&gt;For projects with multiple environments, a &lt;code&gt;.glenv.yml&lt;/code&gt; config file replaces repetitive flags:&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;gitlab&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${GITLAB_TOKEN}&lt;/span&gt;      &lt;span class="c1"&gt;# env var expansion supported&lt;/span&gt;
  &lt;span class="na"&gt;project_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12345678"&lt;/span&gt;

&lt;span class="na"&gt;environments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy/.env.staging&lt;/span&gt;
  &lt;span class="na"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy/.env.production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then sync all environments at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;glenv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;glenv processes environments alphabetically, reports results per environment, and aggregates errors so you see the full picture even if one environment fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate limiting that actually works
&lt;/h2&gt;

&lt;p&gt;GitLab.com allows ~2,000 API requests per minute. With 5 concurrent workers and no rate limiter, you'll hit that ceiling on any non-trivial project.&lt;/p&gt;

&lt;p&gt;glenv uses a token bucket rate limiter shared across all workers. The default is 10 requests/second — well under the limit, but fast enough to sync 100 variables in about 10 seconds. When GitLab returns a 429, glenv reads the &lt;code&gt;Retry-After&lt;/code&gt; header, waits, then retries with exponential backoff.&lt;/p&gt;

&lt;p&gt;For self-hosted instances you can push it harder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;glenv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; .env &lt;span class="nt"&gt;-e&lt;/span&gt; production &lt;span class="nt"&gt;--workers&lt;/span&gt; 10 &lt;span class="nt"&gt;--rate-limit&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  CI/CD pipeline integration
&lt;/h2&gt;

&lt;p&gt;glenv can run inside your GitLab pipeline itself — useful for promoting variables between environments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitlab-ci.yml&lt;/span&gt;
&lt;span class="na"&gt;sync-variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;golang:1.23-alpine&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;go install github.com/ohmylock/glenv/cmd/glenv@latest&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;glenv sync -f deploy/.env.${CI_ENVIRONMENT_NAME} -e ${CI_ENVIRONMENT_NAME}&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;GITLAB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DEPLOY_TOKEN}&lt;/span&gt;
    &lt;span class="na"&gt;GITLAB_PROJECT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CI_PROJECT_ID}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A few things on the roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Group-level variables support (not just project-level)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;glenv import&lt;/code&gt; from an existing GitLab project (clone variables between projects)&lt;/li&gt;
&lt;li&gt;Watch mode — detect &lt;code&gt;.env&lt;/code&gt; file changes and sync automatically&lt;/li&gt;
&lt;li&gt;GitHub Actions artifact: pre-built binary for pipeline use without &lt;code&gt;go install&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're dealing with GitLab CI/CD variables at any scale beyond a handful of keys, give it a try.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/ohmylock/glenv" rel="noopener noreferrer"&gt;github.com/ohmylock/glenv&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback, issues, and PRs are all welcome. If it saves you time, a star helps others find it. ⭐&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>devops</category>
      <category>go</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
