Blog

Building an in-house CI/CD platform: a field report

Running a dozen repositories and about twenty applications, libraries and websites quickly raises a simple but structural question: do you industrialize the build and deployment chain, or keep patching it together repo by repo? The choice made here was to design a self-hosted CI/CD platform, treated as a proper internal product — with its own design rules, auto-generated documentation, and its own lifecycle.

An important clarification before going further: this platform isn't meant to be "better" than an established SaaS offering (GitHub Actions, GitLab CI, Bitbucket Pipelines...) in terms of features or operational comfort. It's a sovereignty choice — keeping code, artifacts and build data under your own roof — accepted with its costs, and relevant above all for an organization that already has the in-house IT capacity to operate it over time. That point is developed further below.

This post doesn't walk through exact configuration: it describes the architecture decisions and the reasoning behind them.

Overview

flowchart TB
    DEV((Developer)) -->|push / pull requests| GITEA

    subgraph HOST["Containerized infrastructure (single host)"]
        direction TB
        NGINX["Nginx — reverse proxy"]

        subgraph CICD["cicd stack"]
            direction LR
            GITEA["Gitea
Git forge"] WOODP["Woodpecker
CI/CD server"] AGENT["Woodpecker
agent"] NEXUS["Nexus
binary artifacts"] VERD["Verdaccio
npm registry"] MINIO["Minio
object storage"] PG[("Postgres")] end subgraph ADMIN["admin stack"] PORTAINER["Portainer"] end subgraph ACME["acme stack"] ACMESH["acme.sh
TLS certificates"] end NGINX --> GITEA NGINX --> WOODP NGINX --> NEXUS NGINX --> VERD NGINX --> MINIO GITEA -->|webhook| WOODP WOODP --> AGENT WOODP --> PG AGENT --> NEXUS AGENT --> VERD AGENT --> MINIO ACMESH -.certificates.-> NGINX PORTAINER -.monitoring.-> CICD end

Three logical stacks (cicd, admin, acme), a single template base, one host able to carry all of it — generated from a centralized Python configuration rather than assembled by hand.

A self-hosted platform, chosen rather than endured

The chain relies on open source building blocks assembled and operated in-house rather than on SaaS services: Git forge, CI engine, binary artifact repository, package registry, object storage, relational database and reverse proxy all run in containers on infrastructure controlled end to end. That choice has a cost — operations sit with the team — but it gives full control over availability, artifact retention and data location, for a volume of projects large enough to justify the investment.

A deliberately lightweight infrastructure

At no point does the platform's footprint exceed the actual need. Component choices systematically favor the leanest tool capable of carrying the load: a single-binary Git forge rather than a full DevOps suite, an agent-based CI engine with a native YAML format rather than a heavyweight legacy orchestrator, a minimal npm registry rather than a general-purpose enterprise one. No container orchestrator for three Docker Compose stacks — the complexity of a Kubernetes wouldn't have added anything at this scale, only abstraction layers to operate.

That leanness shows up in day-to-day operations too: the entire chain (forge, CI, artifacts, registry, object storage, database, reverse proxy) fits on a single host, with an image set mostly based on minimal variants (alpine, slim) for pipeline steps. It's also what makes a host that goes to sleep on a schedule credible: an oversized infrastructure wouldn't have that latitude.

Infrastructure as code, without the gas factory

Rather than duplicating docker-compose.yml files for each environment, the entire configuration relies on a homemade templating engine: composition files are generated from parameterized templates, merged by a dedicated tool, and fed from a single source of truth written in Python. Each value (port, volume path, domain) exists in exactly one place; everything else — CI services, admin portal, certificate renewal — derives from it. The goal wasn't to adopt a general-purpose infrastructure-as-code tool, but to build the minimal tool matching the actual need, with a deliberately uniform formalism from one configuration file to the next.

A convention that eliminates an entire class of bugs

One simple rule structures the whole port system: a container's internal port and the port exposed on the host are never identical. That choice, which might seem trivial, removes in practice a recurring source of confusion during troubleshooting and configuration changes — you never have to wonder whether a port number refers to the internal or external world. It's the kind of convention that costs nothing to put in place early and costs a lot to retrofit later.

Two pipelines, one truth

Build and deployment are deliberately decoupled into two distinct pipelines rather than a single monolithic flow:

  • The build pipeline triggers on every push: it cleans the workspace, automatically derives the semantic version from the commit message, compiles, packages, publishes the binary artifact to the artifact repository, then tags the corresponding commit.
  • The deploy pipeline triggers independently, on an explicit or manual deployment event. It never rebuilds anything: it fetches the already-published artifact and pushes it to the target.

The link between the two is a metadata object passing through object storage, which guarantees a deployment corresponds exactly to one identified build — no possible drift between what was tested and what's put into production. Transfer to the target host and execution of the remote deployment script are themselves scripted end to end, with no manual step.

This separation also makes it possible to replay a deployment without rebuilding, and to keep a clean boundary between "what produces an artifact" and "what installs it".

sequenceDiagram
    participant Dev as Developer
    participant Gitea
    participant Build as Woodpecker (build pipeline)
    participant Nexus
    participant Minio
    participant Deploy as Woodpecker (deploy pipeline)
    participant Target as Target server

    Dev->>Gitea: push to main
    Gitea->>Build: webhook
    Build->>Build: version derived from commit
    Build->>Nexus: publish versioned artifact
    Build->>Minio: store build metadata
    Build->>Gitea: commit + tag

    Note over Dev,Target: deployment triggered independently

    Dev->>Deploy: deployment event
    Deploy->>Minio: read build metadata
    Deploy->>Nexus: fetch the exact artifact
    Deploy->>Target: transfer the artifact
    Deploy->>Target: run the remote deployment script

One repository, several deployable modules

Each project explicitly declares the modules it exposes for deployment rather than artificially coupling an application to a single target. This indirection lets the same generic mechanism serve an isolated application as well as a set of services shipped together, without duplicating pipeline logic for each case.

Living documentation instead of hand-maintained documentation

A notable design point: architecture diagrams (dependencies between services, networks, volumes) aren't drawn by hand and then forgotten — they're generated directly from the Docker Compose files, using a standard diagram syntax. Architecture documentation therefore can't silently drift from operational reality: it's regenerated on demand from the source of truth.

Operational sobriety

The infrastructure includes operational mechanisms that go beyond simple "it runs":

  • system services cleanly stop and restart container stacks around the host's sleep cycles, so nothing that can be paused keeps running needlessly;
  • a periodic monitoring mechanism automatically restarts the CI agent if it becomes unavailable, with no human intervention.

These are details, but they're precisely the details that distinguish a platform built to last from a stack of tools that only works on day one.

Secrets and access

Secret management (registry credentials, access keys, forge tokens) is isolated from functional configuration rather than scattered across composition files, with a component dedicated to centralized secret management. Sensitive credentials travel through the CI engine's native mechanisms (encrypted secrets) rather than as plaintext variables in pipelines.

Work already under way on what's next

Beyond what exists today, a specification has been written to evolve how configuration for deployed applications is managed: version the configurations themselves in Git (one branch per application/version pair), push them independently of application code, and trigger a hot reload rather than a full redeployment. It's an example of design preceding implementation — the underlying problem (decoupling the code lifecycle from the configuration lifecycle, at the scale of about twenty applications) is stated and solved on paper before being coded.

Who this is for — and who it isn't

None of the above is an argument against SaaS offerings. For a small team with no dedicated IT resource, a managed GitHub Actions or GitLab CI is very likely still the most rational choice: zero operations, automatic updates, vendor support. Self-hosting doesn't make those advantages obsolete, it simply moves the trade-off — less dependency on a third party, in exchange for more operational responsibility.

The profile this kind of platform is built for is therefore specific: a small business that already has a team or a person in IT able to operate a container stack over time (security updates, backups, monitoring, on-call), and for whom control over where and how code and artifacts are hosted matters in its own right — data sovereignty, independence from a Cloud vendor, cost kept in check at the scale of about twenty applications rather than a per-user or per-build-minute subscription. Without that in-house IT capacity, the math flips quickly: the time spent operating it costs more than it saves.

What this shows

At the scale of a dozen repositories and about twenty applications, libraries and sites, the value doesn't come from a miracle tool but from the accumulation of consistent decisions: a single source of truth for configuration, a clean separation between building and deploying, traceability guaranteed by construction rather than by discipline, documentation that can't lie, and as much attention paid to long-term operations as to the initial rollout.

None of this claims to outdo a SaaS platform on feature richness or operational comfort — the goal was elsewhere: keeping full control of a build and deployment chain, for an organization able to take on running it.

Translations: