Home β€Ί Blog β€Ί Jekyll Environment Variables: The Complete Guide
Tutorial

Jekyll Environment Variables: The Complete Guide

How to use environment variables in Jekyll β€” JEKYLL_ENV, accessing env vars in config, conditional builds, and best practices for managing secrets.

Jekyll Environment Variables: The Complete Guide

Environment variables in Jekyll behave differently from how they work in Node.js or Python applications, and that surprises a lot of developers coming from those ecosystems. Jekyll is a static site generator β€” it builds files, then stops. There is no server process reading environment variables at request time. All environment logic runs at build time, and the output is static HTML that knows nothing about the environment it was built in.

Understanding this constraint makes the whole topic much clearer. This guide covers everything from the one built-in environment variable (JEKYLL_ENV) to multi-config strategies for different environments, to the right way to handle secrets that should never appear in your built site.


JEKYLL_ENV: the built-in environment variable

Jekyll ships with exactly one built-in environment variable: JEKYLL_ENV. It defaults to development when running locally. To change it:

JEKYLL_ENV=production jekyll build
JEKYLL_ENV=staging jekyll build

On Netlify, Cloudflare Pages, Vercel, and most CI/CD platforms, JEKYLL_ENV=production is set automatically in the production build environment. On GitHub Actions, you need to set it explicitly (or let the default development apply to all builds, which is why production-only scripts might appear on your dev site unless you set it).

Reading JEKYLL_ENV in Liquid templates

Access the environment in any Liquid template with jekyll.environment:


{% if jekyll.environment == "production" %}
  {% include analytics.html %}
  {% include cookie-banner.html %}
{% endif %}

This is the most common use case: including tracking scripts, cookie consent banners, chat widgets, and other production-only code. In development, these are excluded β€” your local browsing does not pollute your analytics, and the HTML output is simpler to inspect.

Using JEKYLL_ENV in GitHub Actions

If you use a GitHub Actions workflow for deployment, set the environment explicitly in the build step:

- name: Build Jekyll
  run: bundle exec jekyll build
  env:
    JEKYLL_ENV: production

Without this, the workflow runs in development mode, and any {% if jekyll.environment == "production" %} blocks will be skipped β€” meaning your analytics and other production code will be missing from the deployed site.


Multi-config files: environment-specific settings

Jekyll supports loading multiple configuration files that are merged in order. Later files override values from earlier files. This is the standard pattern for environment-specific configuration:

# Development (default) β€” just uses _config.yml
jekyll serve

# Production build β€” merges _config.yml then _config.production.yml
jekyll build --config _config.yml,_config.production.yml

# Staging build
jekyll build --config _config.yml,_config.staging.yml

Structure your config files like this:

_config.yml β€” safe defaults for development:

title: "JekyllHub"
description: "A Jekyll themes marketplace."
url: "http://localhost:4000"
baseurl: ""

# Analytics (empty in development)
google_analytics: ""
facebook_pixel: ""

# Features
show_drafts: true
future: false
limit_posts: 20    # Faster builds in development

_config.production.yml β€” overrides for production:

url: "https://jekyllhub.com"

google_analytics: "G-XXXXXXXXXX"

show_drafts: false
limit_posts: 0     # Build all posts
future: false

_config.staging.yml β€” overrides for staging:

url: "https://staging.jekyllhub.com"
baseurl: ""

google_analytics: ""   # No analytics on staging
robots: noindex        # Custom key you read in a robots.html layout

In your GitHub Actions workflow:

- name: Build Jekyll (production)
  run: bundle exec jekyll build --config _config.yml,_config.production.yml
  env:
    JEKYLL_ENV: production

Accessing site config values in templates

Any key you define in _config.yml is available in Liquid as site.key_name:

# _config.yml
google_analytics: "G-XXXXXXXXXX"
support_email: "help@jekyllhub.com"
twitter_handle: "jekyllhub"

<!-- _includes/analytics.html -->
{% if jekyll.environment == "production" and site.google_analytics != "" %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics }}"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag("js", new Date());
  gtag("config", "{{ site.google_analytics }}");
</script>
{% endif %}


<!-- Footer contact link -->
<a href="mailto:{{ site.support_email }}">Contact us</a>

Using _config.yml values rather than hard-coding makes the site easier to update β€” change one value in config and it propagates everywhere.


Reading system environment variables in Jekyll

This is where Jekyll diverges from Node.js and Python. By default, Jekyll does not read shell environment variables into the Liquid template context. You cannot access ENV['MY_VAR'] directly in a .html file.

There are two workarounds:

Option 1: Custom plugin (Ruby)

In _plugins/environment_variables.rb:

module Jekyll
  class EnvironmentVariablesGenerator < Generator
    def generate(site)
      # Expose specific environment variables to Liquid as site.env.*
      site.config['env'] ||= {}
      site.config['env']['BRANCH'] = ENV['BRANCH'] || 'unknown'
      site.config['env']['DEPLOY_ID'] = ENV['DEPLOY_ID'] || ''
    end
  end
end

Then in templates:


<!-- Show build info (useful for staging) -->
{% if jekyll.environment != "production" %}
  <p class="build-info">Branch: {{ site.env.BRANCH }} | Deploy: {{ site.env.DEPLOY_ID }}</p>
{% endif %}

This works for non-sensitive values like branch names or deploy IDs. It does not work on GitHub Pages (which disallows custom plugins), but does work when building with GitHub Actions.

Option 2: Generate a data file at build time

Before running jekyll build, generate a _data/env.yml file with values you want available:

#!/bin/bash
# generate-env.sh
cat > _data/env.yml << EOF
branch: ${BRANCH:-development}
deploy_id: ${DEPLOY_ID:-local}
build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
EOF

bundle exec jekyll build --config _config.yml,_config.production.yml

In templates:


Build: {{ site.data.env.build_date }}

This approach works everywhere, including GitHub Pages, because the data file is generated before Jekyll runs.


Handling secrets properly

The most important rule: never put secret API keys, tokens, or passwords in _config.yml or any file committed to your repository. Once committed to Git, a secret is compromised β€” even if you delete it later, it remains in the history.

The challenge with Jekyll is that everything in _site/ is public. Any secret you embed in a Liquid template ends up in the built HTML. Strategies for handling sensitive values:

Secrets that should stay server-side β€” API keys for third-party services, database credentials, webhook secrets. These should never appear in your Jekyll source or built output. Use server-side proxy functions (Netlify Functions, Cloudflare Workers, Vercel Edge Functions) to make authenticated requests and return only the data Jekyll or your browser JavaScript needs.

Secrets that need to be in the build environment β€” values like private RubyGems credentials or build-only tokens. Store these as environment variables in your CI/CD platform (GitHub Actions secrets, Netlify environment variables) and access them in build scripts, not in Jekyll templates.

Semi-public keys β€” things like Algolia search-only API keys or Firebase public config. These are designed to be public (they are scoped to read-only operations) and can safely go in _config.yml and your templates. Document which keys are safe to expose and which are not.


Practical patterns

Analytics only in production

# _config.yml
google_analytics: ""

# _config.production.yml
google_analytics: "G-XXXXXXXXXX"

{% if site.google_analytics != "" and jekyll.environment == "production" %}
  {% include analytics.html %}
{% endif %}

The double check (site.google_analytics != "" and jekyll.environment == "production") ensures analytics never appear even if you accidentally set the analytics ID in your dev config.

Drafts only in development

# Serve with drafts (development)
jekyll serve --drafts

# Or use config:
# _config.development.yml
show_drafts: true
jekyll serve --config _config.yml,_config.development.yml

Robots noindex on staging

Create robots.html in _pages/:


---
layout: null
permalink: /robots.txt
---
{% if site.noindex %}
User-agent: *
Disallow: /
{% else %}
User-agent: *
Allow: /
Sitemap: {{ site.url }}/sitemap.xml
{% endif %}

# _config.staging.yml
noindex: true
# _config.yml
maintenance: false
maintenance_message: ""

{% if site.maintenance %}
  <div class="maintenance-banner">
    {{ site.maintenance_message | default: "We are performing maintenance. Back shortly." }}
  </div>
{% endif %}

Toggle by changing maintenance: true and deploying β€” no code change needed.


What Jekyll cannot do with environment variables

Coming from a Node.js background, a few things will feel missing:

No .env file support. Jekyll has no equivalent to dotenv. You cannot create a .env file and have variables automatically available in _config.yml or templates.

No runtime environment reading. Because Jekyll generates static HTML, there is no opportunity to read environment variables at request time. Everything happens at build time.

No secret interpolation in _config.yml. You cannot write google_analytics: <%= ENV['GA_ID'] %> in a standard _config.yml. ERB interpolation in config requires a custom setup and does not work on GitHub Pages.

For most Jekyll sites, these limitations are not a practical problem. The multi-config file approach handles the vast majority of environment-specific needs cleanly and without workarounds.


Keep your environment strategy as simple as possible. For most sites, JEKYLL_ENV plus one _config.production.yml file covers everything you need β€” production URLs, analytics IDs, and feature flags. Resist the urge to build elaborate environment management before you need it.

For more on Jekyll configuration, see our comprehensive _config.yml guide.


Using jekyll.environment beyond analytics

Most tutorials show JEKYLL_ENV only for toggling analytics scripts. It is more versatile than that.

Show build metadata in non-production environments

A build info bar is useful for QA teams reviewing staging deployments. They can see exactly which environment they are on and when the site was last built:


{% unless jekyll.environment == "production" %}
<div class="build-info-bar">
  Environment: <strong>{{ jekyll.environment }}</strong>
  | Built: {{ site.time | date: "%Y-%m-%d %H:%M UTC" }}
</div>
{% endunless %}

Style it clearly so it is impossible to confuse staging with production:

.build-info-bar {
  background: #f59e0b;
  color: #000;
  text-align: center;
  padding: 6px;
  font-size: 0.85rem;
  font-weight: 500;
}

Noindex meta tag on staging

You do not want staging or preview deployments indexed by Google:


{% if jekyll.environment != "production" %}
  <meta name="robots" content="noindex, nofollow">
{% endif %}

Disable CDN on development

If you serve assets from a CDN in production but want to reference local files in development:


{% if jekyll.environment == "production" and site.cdn_url %}
  {% assign asset_base = site.cdn_url %}
{% else %}
  {% assign asset_base = "" %}
{% endif %}

<img src="{{ asset_base }}{{ '/assets/images/logo.png' | relative_url }}">

In _config.yml:

cdn_url: ""  # Empty in development

# In _config.production.yml
cdn_url: "https://cdn.jekyllhub.com"

The site.time variable

Jekyll exposes a related variable worth knowing: site.time. It is the time the current build started, as a Ruby Time object. This is useful for cache-busting assets, showing a β€œlast updated” timestamp, or generating time-based content:


<footer>
  <p>Last updated: {{ site.time | date: "%B %-d, %Y" }}</p>
</footer>

<!-- Cache-bust CSS file -->
<link rel="stylesheet" href="{{ '/assets/css/main.css' | relative_url }}?v={{ site.time | date: '%s' }}">

The date: '%s' format outputs a Unix timestamp (seconds since epoch), which changes on every build β€” a simple cache-busting strategy.


Environment variables in GitHub Actions secrets

When using GitHub Actions for deployment, sensitive values go in GitHub Secrets (Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret). Reference them in your workflow:


- name: Build Jekyll
  run: bundle exec jekyll build --config _config.yml,_config.production.yml
  env:
    JEKYLL_ENV: production
    # Pass secrets to the build environment
    ALGOLIA_ADMIN_KEY: ${{ secrets.ALGOLIA_ADMIN_KEY }}

If you use the jekyll-algolia plugin to index your content, the admin key needs to be available during the build β€” but never in _config.yml. The above approach puts it in the environment where the gem can access it via ENV['ALGOLIA_ADMIN_KEY'] without ever committing it to your repository.

The GITHUB_TOKEN secret is provided automatically by Actions and covers most GitHub API operations β€” you rarely need to create a separate token for basic deploy operations.


When a production build behaves differently from a local build, systematically check:

Which config files are being merged? Log them at the start of your build script:

echo "Config files: $CONFIG_FILES"
bundle exec jekyll build --config $CONFIG_FILES

Is JEKYLL_ENV set? Add a check:

echo "JEKYLL_ENV: $JEKYLL_ENV"

Are production-only includes appearing? Check the built _site/ output for analytics scripts and other conditional content. If they are missing, either JEKYLL_ENV is not set or the include path is wrong.

Are environment variables reaching your plugin? In a Ruby plugin, add a debug line:

puts "ALGOLIA_KEY set: #{!ENV['ALGOLIA_ADMIN_KEY'].nil?}"

This will appear in the build output without exposing the actual key value.

The most common issue is forgetting to set JEKYLL_ENV=production in the CI environment. The second most common is a typo in a --config flag path, causing the production config file to be silently ignored.


Combining environment variables with Jekyll hooks

If you build with a custom Ruby plugin (not available on default GitHub Pages), you can hook into Jekyll’s build lifecycle to perform environment-aware actions programmatically.

Jekyll hooks run at specific points during the build: site:after_init, site:after_reset, site:post_read, site:pre_render, site:post_write, and others. Combined with environment variable reading, this lets you do things like skip generating certain pages in development:

# _plugins/development_only.rb
Jekyll::Hooks.register :site, :post_read do |site|
  if Jekyll.env == 'development'
    # Remove future posts from the site object in development
    # (preventing accidental reveals during local review)
    site.posts.docs.reject! { |post| post.data['draft'] }
  end
end

Or log useful information during builds:

Jekyll::Hooks.register :site, :post_write do |site|
  if Jekyll.env == 'production'
    puts "Production build complete. #{site.posts.docs.size} posts generated."
    puts "Build time: #{site.time}"
  end
end

Using Jekyll.env in Ruby (rather than jekyll.environment in Liquid) gives the same value β€” the current JEKYLL_ENV setting β€” but accessible in plugin code.

The Jekyll build process is fundamentally simpler than most web frameworks β€” no server, no runtime, no configuration loading at request time. That simplicity is a feature. Keep your environment strategy equally simple: JEKYLL_ENV for conditional includes, a separate _config.production.yml for deployment-specific values, and platform secrets for anything sensitive. These three tools handle 95% of real-world Jekyll environment management without the complexity that comes from trying to force a static site generator to behave like a dynamic application.

Share LinkedIn