When launching an engineering publication dedicated to enterprise cybersecurity, Zero Trust, and DevSecOps, building on a standard monolithic CMS like WordPress or Ghost felt fundamentally flawed. Traditional content management platforms require live database connections, PHP or Node.js runtimes, administrative logins, and continuous patch management—presenting an unnecessary attack surface for what is fundamentally read-only technical content.
Instead, we architected Code and Cypher as an enterprise-grade static publishing engine. Every commit is treated like a software release: scanned for leaked secrets, compiled into immutable static artifacts, validated against a suite of automated Python security quality gates, and deployed across edge delivery networks.
However, a great publishing engine should not be an isolated monolith, nor should it be tightly coupled to a single cloud provider. In this article—Part 1 of our DevSecOps Publisher series—we examine how to build a platform-agnostic build and verification engine, how to manage a two-tier public/private repository architecture, and how to safely expose live build telemetry.
1. Architectural Philosophy: The Agnostic Core
A common mistake when building CI/CD pipelines for static sites is hardcoding deployment logic (such as Cloudflare Pages, Netlify, or AWS S3) directly into the verification process.
A well-architected pipeline follows the Single Responsibility Principle:
- The Core Gatekeeper (Platform-Agnostic): Takes source markdown and theme templates, scans for credentials, compiles the site, and runs rigorous automated tests on the compiled HTML. It outputs a cryptographically verified static artifact bundle (
./public). It knows nothing about Cloudflare, AWS, or host credentials. - The Deployment Adapter (Pluggable): Takes the verified artifact bundle and deploys it to the destination of choice (Cloudflare Pages, GitHub Pages, AWS S3 + CloudFront, or an on-premises Nginx cluster).
graph TD
subgraph Core Gatekeeper [Platform-Agnostic Engine]
A[Git Commit] --> B[Gitleaks Secret Scan]
B --> C[Hugo Static Compilation]
C --> D[Headless Python Security & Functional Gates]
D --> E[Verified Artifact: ./public]
end
subgraph Pluggable Deployment Adapters [Destination Layer]
E -->|Adapter A| F[Cloudflare Pages Deployer]
E -->|Adapter B| G[GitHub Pages Deployer]
E -->|Adapter C| H[AWS S3 + CloudFront Invalidation]
end
By decoupling these layers, the entire build and testing harness can be open-sourced as a standalone starter template (hugo-devsecops-starter) without exposing deployment tokens or forcing hosting opinions onto community users.
2. The Two-Tier Architecture: Public Engine, Private Vault
If you open-source your site’s git repository, you immediately confront a serious operational hurdle: draft and embargo exposure.
[!WARNING] On GitHub, repository visibility is all-or-nothing. There is no concept of a “private branch” inside a public repository. If a repository is public, every branch, commit, pull request, and unpublished draft in
content/is world-readable.
For an author publishing vulnerability analyses, embargoed research, or multi-week series, committing unpublished drafts to a public repository is a non-starter. To resolve this, we implement a Two-Tier Architecture:
graph LR
subgraph Public Repository
PUB[SixFiveMil/hugo-devsecops-starter]
PUB_TESTS[Agnostic tests/ & reusable CI]
end
subgraph Private Authoring Vault
PRIV[SixFiveMil/code-and-cypher]
PRIV_CONTENT[content/ drafts & launch plans]
PRIV_DEP[.github/workflows/deploy.yml]
end
PUB -->|Pattern 1: Upstream Remote Merge OR
Pattern 2: Reusable workflow_call| PRIV
Pattern 1: The Git Upstream Remote
The public starter is registered as an upstream remote inside the private authoring vault:
git remote add starter https://github.com/SixFiveMil/hugo-devsecops-starter.git
When improvements are made to the generic test suite or build configs in the public starter, you pull them into your private vault:
git fetch starter
git merge starter/main
Git merges updates to tests/ and configuration files cleanly, while leaving your private content/ posts, drafts, and launch calendars untouched.
Pattern 2: Reusable GitHub Actions (workflow_call)
Alternatively, the public starter can host the core verification logic as a reusable GitHub Actions workflow (reusable-verify.yml):
# In public repo: SixFiveMil/hugo-devsecops-starter/.github/workflows/reusable-verify.yml
name: Reusable Hugo DevSecOps Verification
on:
workflow_call:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: peaceiris/actions-hugo@v3
with: { hugo-version: "latest", extended: true }
- uses: gitleaks/gitleaks-action@v2
- run: hugo --minify --gc --cleanDestinationDir
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Run Quality & Security Gates
run: python -m unittest discover -s tests -v
- name: Archive Verified Artifacts
uses: actions/upload-artifact@v4
with:
name: verified-public-site
path: public/
retention-days: 1
The private repository simply invokes the public verifier and attaches its own private deployment adapter:
# In private repo: .github/workflows/deploy.yml
name: Deploy codeandcypher.com
on:
push:
branches: [main]
schedule:
- cron: '0 14 * * *'
jobs:
verify:
uses: SixFiveMil/hugo-devsecops-starter/.github/workflows/reusable-verify.yml@main
secrets: inherit
deploy:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: verified-public-site, path: public }
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=codeandcypher --commit-dirty=true
Engineering Pitfalls: Lessons from Operationalizing Reusable Workflows
While reusable workflows are immensely powerful for maintaining clean architecture, connecting a private publishing vault to a public starter pipeline introduced several non-obvious engineering traps that every DevSecOps practitioner should know:
- The “Caller Syntax” Trap: A job that invokes a reusable workflow via
uses:is an orchestration job. Adding standard keys likename:,runs-on:, orenv:at that job level will cause GitHub Actions to reject the workflow during schema parsing before running any steps. - The “Nested Concurrency Deadlock”: If both the caller workflow and the reusable workflow define
concurrency: group: ${{ github.workflow }}-${{ github.ref }}, the child workflow evaluates${{ github.workflow }}to the caller’s workflow title. Both workflows attempt to lock the exact same mutex: the caller holds the lock while waiting for the child, while the child cannot start because the lock is held by the caller. GitHub detects this circular wait and aborts with an immediate deadlock cancellation. Rule: Never place top-level concurrency locks in a reusable workflow; leave concurrency management strictly to the caller. - Secret Propagation: By default, reusable workflows do not inherit caller secrets—including
secrets.GITHUB_TOKEN. For downstream steps like Gitleaks secret scanning to authenticate, the calling job must explicitly specifysecrets: inherit.
3. Headless Security Quality Gates in Python
Static site security requires verifying compiled output, not just source code. Our pipeline executes 23 automated tests against the compiled ./public directory before any deployment can proceed.
Control A: Draft & Future-Date Leakage Prevention
Hugo supports draft posts (draft: true) and future dates (buildFuture = false). However, if misconfigured or if shortcodes fail, titles or excerpts can inadvertently leak into sitemaps or client-side search indices (index.json).
Our security gate validates both draft containment and future-date isolation:
def test_no_draft_leakage(self):
"""Ensure that markdown files marked draft: true are never compiled into public/."""
content_files = get_all_content_files()
draft_slugs = [
(path, fm.get("slug", path.stem), fm.get("title", ""))
for path, fm in content_files
if fm.get("draft", "").lower() == "true"
]
search_index = get_search_index()
search_titles = {item.get("title", "") for item in search_index}
for path, slug, title in draft_slugs:
with self.subTest(draft=path.name):
# Verify no HTML page was generated
matches = list(PUBLIC_DIR.glob(f"**/{slug}*"))
self.assertEqual(len(matches), 0, f"Draft '{path.name}' found in public artifacts: {matches}")
# Verify title is omitted from search index
self.assertNotIn(title, search_titles, f"Draft title '{title}' leaked into search index!")
Control B: Subresource Integrity (SRI) on External CDNs
When pulling third-party scripts or stylesheets (e.g. KaTeX math rendering, font glyphs), a compromised CDN could inject malicious JavaScript into visitors’ sessions. The test suite parses all <script> and <link> tags pointing to external CDNs and asserts that cryptographic integrity hashes (SHA-256 / SHA-384) are present.
Control C: Local Workstation Path Sanitization
Authoring tools and local build scripts can inadvertently bake local filesystem paths (such as Windows user profile directories or CI runner workspace paths) into compiled HTML. Our test suite scans all compiled files to ensure zero internal environment paths are leaked.
4. Edge Hardening & Defense-in-Depth
Even for static websites, HTTP response headers provide a critical layer of defense-in-depth. In our deploy adapter, we enforce strict browser security headers via static/_headers:
/*
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
- HSTS Preload: Guarantees all traffic is forced over HTTPS for at least one full year, neutralizing SSL-stripping and downgrade attacks.
nosniff: Prevents browsers from MIME-type sniffing, mitigating drive-by script injection.- Permissions-Policy: Restricts access to sensitive client APIs (camera, microphone, geolocation).
- Frame Ancestors: Eliminates clickjacking attacks by forbidding external domains from embedding site content in iframes.
5. Live Build Telemetry & Colophon
Displaying live build telemetry provides transparency and reinforces an engineering-first culture. However, dumping raw CI/CD console logs is dangerous because terminal streams frequently echo internal runner paths, IP addresses, or temporary environment variables.
The Curated Telemetry Pattern
Instead of raw logs, the CI/CD pipeline generates a structured, sanitized data file (data/build_telemetry.json) right before static compilation:
mkdir -p data
cat <<EOF > data/build_telemetry.json
{
"build_time_utc": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')",
"git_commit_short": "$(git rev-parse --short HEAD)",
"hugo_version": "v0.128.0 Extended",
"quality_gates": "23/23 tests passed",
"gitleaks_status": "Clean (0 secrets detected)",
"hosting": "Cloudflare Pages Anycast Edge"
}
EOF
Because Hugo natively loads data files, rendering live telemetry in your footer or /colophon/ page is effortless and completely risk-free:
<div class="telemetry-badge">
<span>🟢 Status: Healthy</span>
<span>• Build: {{ .Site.Data.build_telemetry.git_commit_short }}</span>
<span>• Security Gates: {{ .Site.Data.build_telemetry.quality_gates }}</span>
<span>• Gitleaks: {{ .Site.Data.build_telemetry.gitleaks_status }}</span>
</div>
[!TIP] Explore the Open-Source Engine: The production-hardened template discussed throughout this series is open-sourced at
SixFiveMil/hugo-devsecops-starter. You can also inspect all active open-source tools on our Projects Directory.
What’s Next in the Series
In Part 2 of The DevSecOps Publisher, we will walk through the step-by-step implementation of the Two-Tier GitHub repository structure, including setting up Git upstream remotes and automated synchronization without merge conflicts.
In Part 3, we explore pluggable edge adapters for AWS S3/CloudFront vs. Cloudflare Pages, accompanied by real-time build telemetry widgets.