One MacBook, a clean desktop, no Jenkins tray icon, no GitHub Actions public runner client, no PostgreSQL or MySQL daemon. Open a browser, click a button — image build, push, k3s deploy, service probe — and the entire pipeline runs in minutes. All state lives in a single local JSON file, all code compiled into one binary.
This is the cicdkit scenario: a pure Go standard library backend with zero third-party dependencies + an embedded React frontend = one binary that handles everything from configuration management to pipeline execution (GitHub: erishen/cicdkit).
This article dissects cicdkit's internal structure along the data flow of a real release — from a browser click to a k3s-deployed image — focusing on the engineering trade-offs it makes to "run reliably and run safely."
TL;DR
- One binary, zero external dependencies: a pure Go standard library backend + an embedded React/Vite frontend (go:embed), compiled into a single file; all state lives in a local
store.json, with no external database like PostgreSQL/MySQL. - One local-first pipeline: from a single Web UI, complete configuration management → Docker build → image push → k3s deploy → SSH deploy to bare metal → service probe, without relying on public CI runners.
- Security model as a hard constraint: non-localhost binding requires an explicit
API_TOKENor startup is refused; host command execution is controlled; secrets are redacted in list responses viaRedacted()/MergeSecrets()and the on-disk file is mode 0600. - 8 multi-language examples (Go/Rust/Python/Ruby/PHP/Java/.NET/Node) to run build and deploy end-to-end with one click.
- Optional extras: LLM failure diagnosis + knowledge base, plus a gitleaks pre-commit hook to prevent secret leaks.
Entry Point and Embedding: From main.go to a Single Binary
The startup sequence executes in a strict order:
CLI argument parsing → .env loading → config loading → store initialization (NewJsonStore) → runner creation (pipeline.New) → frontend embed (compile-time go:embed, runtime fs.Sub extracts web/dist) → AUTO_TOKEN handling → IsLoopback security gate → HTTP server startup (graceful shutdown includes Flush)
The config loading phase also handles .env and .env.local files:
if err := config.LoadDotEnv(); err != nil {
log.Printf("Failed to load .env (ignoring): %v", err)
}
cfg, err := config.Load(*configPath)
This allows sensitive information such as SSH connection keys to reside in environment variable files rather than being written directly into project JSON or UI forms. Real environment variables take priority over file values.
After configuration and before the server starts, there is a critical security check — IsLoopback(). If the listen address is not bound to localhost, an explicit API_TOKEN must be set; otherwise log.Fatalf rejects startup immediately.
How the Frontend Comes In: Static Assets and SPA Fallback
For known paths, static files are served directly by http.FileServer; for all unmatched frontend routes, the request falls back to index.html, letting React Router take over — this is the classic SPA pattern, implemented with particular conciseness in a Go single binary.
Authentication is handled by the withAuth middleware. The /api/health and /api/version endpoints are open to all requests; all other /api/* paths require an API Token (passed via Bearer or X-API-Token header).
When the backend injects a one-time token through window.__CICD_API_TOKEN__, the frontend automatically writes it to localStorage:
if (window.__CICD_API_TOKEN__) {
writeToken(window.__CICD_API_TOKEN__)
}
The normalizeToken function handles the common quoting issue found in .env files — users often copy-paste tokens along with surrounding double quotes, causing a token length mismatch between frontend and backend and resulting in persistent 401 errors. Normalization is applied on both read and write sides:
function normalizeToken(t) {
if (!t) return ''
const s = String(t).trim()
return s.replace(/^["']|["']$/g, '')
}
The case where multiple concurrent requests simultaneously encounter 401 is guarded by the tokenPrompting flag: only one prompt is allowed in flight at any given time, preventing multiple "Please enter Token" dialogs from cascading on the homepage.
Configuration and Security: Localhost Binding Is a Hard Constraint
Since the backend executes host commands such as docker and kubectl, a cicdkit instance exposed to the public internet without authentication is equivalent to opening arbitrary command execution. Therefore, when not bound to localhost, if only AUTO_TOKEN is present without an explicit API_TOKEN, the program exits directly:
if !cfg.Server.IsLoopback() {
switch {
case cfg.Server.APIToken == "":
log.Fatalf("Security error: listen address %s is not localhost-only, but API_TOKEN is not set. This platform executes docker/kubectl on the host, effectively exposing command execution to the entire internet. Please set the API_TOKEN environment variable, or change the address to 127.0.0.1.", cfg.Server.Addr)
case autoTokenUsed:
log.Fatalf("Security error: listen address %s is not localhost-only, but AUTO_TOKEN is being used (the token is embedded in the frontend page and invalid for external networks). Please restart after setting an explicit API_TOKEN environment variable.", cfg.Server.Addr)
}
}
SSH fields implement key redaction through Redacted() and MergeSecrets(), ensuring that plaintext credentials are never leaked in list responses.
API Routes and Project Lifecycle
Routes are dispatched via path prefix matching:
action := parts[1]
switch action {
case "build":
s.trigger(w, r, id, "build")
case "pipeline":
s.trigger(w, r, id, "pipeline")
case "deploy":
s.triggerDeploy(w, r, id)
case "validate":
s.handleValidateProject(w, r, id)
case "probe":
s.handleProbeProject(w, r, id)
case "generate":
// GET previews scaffolded files, POST writes them to disk
if r.Method == http.MethodPost {
s.handleGenerateApply(w, r, id)
} else {
s.handleGeneratePlan(w, r, id)
}
}
Server-side path patterns are safer — users browse directories within allowed scopes through the /api/fs/roots and /api/fs/list endpoints.
Filesystem Browsing and Path Safety
Hidden entries (those starting with .) are skipped, and directories and files are sorted separately before being merged into the response.
Pipeline Execution and Persistence
When a project triggers a build, pipeline, or deploy action, the runner decides the build flow based on project configuration (Dockerfile build → image push → deploy to k3s) and writes execution records to the JSON store.
The HTTP server's timeout configuration reflects tolerance for "long-running tasks":
httpSrv := &http.Server{
Addr: cfg.Server.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
}
Build Timestamp: Confirming You're Connected to the Latest Version
The frontend footer displays this timestamp. Paired with the frontend UI_BUILD timestamp, users can instantly confirm whether their browser is connected to the latest compiled binary — extremely useful when troubleshooting "I changed the code but nothing happened." The format uses space-separated, timezone-free local time, consistent with the frontend UI_BUILD styling.
Source Code Navigation
- cmd/server/main.go — startup sequence, AUTO_TOKEN, IsLoopback security gate, graceful shutdown
Flush() - cmd/server/web/src/api.js — frontend auth:
normalizeToken/writeToken/tokenPrompting - internal/api/server.go — route dispatch,
withAuthmiddleware, HTTP timeout config - internal/api/handlers.go — API handlers (build/pipeline/deploy/validate/probe/generate)
- internal/api/fs.go —
/api/fs/roots|listfilesystem browsing and path safety - internal/store/store.go — in-process
RWMutex+ 200ms coalescing window + atomicrenamepersistence - internal/config/config.go —
IsLoopback, config loading - internal/config/dotenv.go —
.env/.env.localloading
Project Address
- GitHub: erishen/cicdkit
FAQ
Why does cicdkit require an explicit API_TOKEN when not bound to localhost, and why can't AUTO_TOKEN be used?
AUTO_TOKEN is a one-time random token auto-generated at startup and injected into a JavaScript variable in the frontend page. Since the token is written into the frontend source, any user who can access that page can see it — meaning AUTO_TOKEN cannot prevent cross-site access. When not bound to localhost, the service is exposed on the network and requires an explicit API_TOKEN to ensure only external requests holding the correct token can access it.
How does cicdkit avoid multiple concurrent requests triggering 401 pop-ups simultaneously?
The frontend api.js uses a tokenPrompting boolean flag. When the first 401 request triggers the prompt, it sets the flag to true. Subsequent concurrent 401 requests detect the flag and throw an error directly instead of opening another dialog. After the user enters the token, the original request is retried, and other failed requests are handled by their own retry logic.
How does cicdkit's JSON file storage ensure write safety?
The store layer uses no OS-level file lock; instead it protects the in-memory dataset with an in-process read-write lock (sync.RWMutex) — all reads take the read lock, all writes take the write lock. Each write does not hit disk directly; instead it calls persist(), which starts a 200ms coalescing window (persistDelay) that collapses the SaveRun calls fired on every build stage into a single disk write. On flush, flushNow() first takes the read lock to snapshot memory, writes to store.json.tmp, then atomically replaces the file with os.Rename, so readers never see a half-written file; the file mode is 0600 because the store may hold secrets such as registry passwords / ssh key paths. On graceful shutdown, after receiving SIGINT/SIGTERM the HTTP server waits up to 10 seconds for in-flight requests to finish, then calls Flush() to stop the pending timer and force the last coalescing window to disk, avoiding loss of recent operations on process exit.