DEV Community

Cover image for Why PikoCI uses HCL instead of YAML
Francesc Gil
Francesc Gil

Posted on

Why PikoCI uses HCL instead of YAML

Why PikoCI uses HCL instead of YAML

Almost every CI/CD tool uses YAML. GitHub Actions, GitLab CI, Concourse, Drone, Woodpecker, all YAML. Before I wrote a single line of PikoCI's code, the first decision I made was that it would not be YAML. I spent time writing pipelines in HCL just to see how they would look compared to the YAML ones I was writing in Concourse. They looked better. That was enough.

What's wrong with YAML

YAML's problems are well documented but worth stating plainly for CI/CD specifically.

No types. Everything is a string unless you're careful. true is a boolean. "true" is a string. yes is also a boolean. no is a boolean too. 0755 is an integer (octal). These are actual footguns that show up in pipeline config regularly.

Indentation is syntax. One extra space and your pipeline silently does something different. No error, just wrong behavior. In a format that humans edit frequently under time pressure, whitespace-sensitive syntax is a constant source of mistakes.

No expressions. If you want to construct a string from two variables in YAML you need template syntax bolted on top, like ${{ }} in GitHub Actions, or just shell interpolation inside a string value. The language itself has no way to express computation.

Anchors and aliases. YAML has a reuse mechanism but it's awkward (&anchor and *alias) and most CI tools don't fully support it anyway. You end up copying and pasting configuration.

Why HCL

HCL is HashiCorp Configuration Language. If you've used Terraform you already know it. It was designed specifically for configuration files that humans write and machines parse, exactly the use case for CI/CD pipeline definitions.

Real types. Strings, numbers, booleans, lists, maps, all distinct. true is always a boolean. "true" is always a string. No ambiguity.

Expressions built in. String interpolation with ${}, function calls like upper(), join(), format(), all part of the language without bolting on a template layer.

Variables with types and defaults. Declare what a pipeline accepts, what type each parameter is, and what the default is. The parser validates on load, so you get an error immediately, not at runtime.

variable "go_image" {
  type    = string
  default = "golang:1.24"
}
Enter fullscreen mode Exit fullscreen mode

Job generation from expressions. for_each and matrix generate job instances from sets or maps, no template engine, no copy-paste:

job "validate" {
  for_each = toset(["lint", "vet", "test"])

  get "git" "app" { trigger = true }
  task "run" {
    run "docker" {
      image = var.go_image
      cmd   = "make ${each.value}"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates three jobs (validate--lint, validate--vet, validate--test) each running its respective make target. In YAML you'd copy the job block three times.

Readable without a reference. HCL reads more like a configuration file and less like a serialization format. Block structure maps naturally to the concepts it represents: a job is a block, a task is a block inside it, a run is a block inside that.

Side by side

The same pipeline (get from git, run tests, deploy on success) in YAML (GitHub Actions style) and HCL (PikoCI).

YAML:

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: go test ./...

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./deploy.sh
        env:
          ENV: production
          VERSION: ${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

HCL (PikoCI):

resource "git" "app" {
  params {
    url    = "https://github.com/myorg/myapp"
    branch = "main"
  }
}

job "test" {
  get "git" "app" { trigger = true }

  task "run-tests" {
    run "exec" {
      path = "go"
      args = ["test", "./..."]
    }
  }
}

job "deploy" {
  get "git" "app" {
    trigger = true
    passed  = ["test"]
  }

  task "deploy" {
    run "exec" {
      path = "./deploy.sh"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things stand out in the comparison.

The HCL version makes the dependency between test and deploy explicit via passed = ["test"], not just a needs: list but an actual resource version constraint. The deploy job only runs when the same git version that passed the test job is available. This is a fundamentally different model: jobs don't just "wait for each other", they share versioned resources.

The resource is declared once and referenced by both jobs. In YAML each job does its own checkout, there's no concept of a shared resource with versioning.

The HCL version is more verbose upfront, you declare the resource explicitly instead of relying on implicit checkout. That explicitness pays off when you have 10 jobs sharing the same resource, or when you need to pin a version across the whole pipeline.

More declarative, easier to maintain

HCL is declarative in a way that YAML isn't. In YAML-based CI/CD you describe what commands to run and in what order, it's closer to a script. In HCL you describe what things are and how they relate to each other. The jobs, resources, and dependencies are named, typed, and structured entities, not a sequence of shell commands glued together with indentation.

This makes a real difference as pipelines grow. A 10-job pipeline in YAML is already hard to navigate. A 10-job pipeline in HCL reads like a map, you can see the structure at a glance, jump to any job by name, and understand how things connect without reading the whole file top to bottom.

Variables, resource declarations, and job definitions all live in clearly separated blocks. When something breaks you know where to look. When you want to change the Go image across all jobs you change one variable, not ten image: lines scattered through the file.

Tooling

Two things PikoCI ships that make working with HCL pipelines smoother:

pikoci pipeline validate validates a pipeline file without a running server. Catches syntax errors, type mismatches, invalid references, and unknown fields. Good for pre-commit hooks or CI checks on the pipeline config itself.

pikoci pipeline validate pipeline.hcl
pikoci pipeline validate pipeline.hcl --var environment=production
Enter fullscreen mode Exit fullscreen mode

pikoci pipeline edit opens a browser-based editor for a local HCL file. The editor validates as you type and shows errors inline. Edit, validate, save, no round-trip to git required for quick fixes. You can also edit pipelines directly from the PikoCI web UI.

pikoci pipeline edit pipeline.hcl
Enter fullscreen mode Exit fullscreen mode

The honest tradeoff

HCL has one real disadvantage: it's less universally known than YAML. Every developer knows YAML. HCL is familiar to Terraform users but not everyone has used Terraform.

In practice this hasn't been a problem, HCL is readable enough that most engineers understand a PikoCI pipeline without having seen HCL before. And the Terraform ecosystem is large enough that "if you know Terraform you already know this" covers a significant part of the DevOps audience.


github.com/pikoci/pikoci ยท pikoci.com ยท Apache 2.0

Top comments (0)