CI/CD & Software Supply Chain›07 · The air-gapped port
Learning Hub / Delivery & Infrastructure as Code / CI/CD & Software Supply Chain

Lesson 07 of 8 · Modules

The air-gapped port

Run CI where there's no internet: Gitea or Forgejo Actions with self-hosted runners, GitHub-Actions-style workflows, mirrored actions, an internal registry and package mirrors, offline vulnerability databases and signing, and a controlled path for bringing content in.

Advanced
Key wordsair-gapped CIGitea ActionsForgejo Actionsact_runnerforgejo-runnermirrored actionsinternal registrypackage mirrorsoffline scanningimport pipeline

Why air-gapped CI?

Defence, government, critical infrastructure, some telecom and industrial environments run networks with no internet access. Pipelines there can't download actions, base images, packages or vulnerability databases on demand. Everything must be inside the gap, brought in through a controlled import process.

Gitea and its community fork Forgejo are lightweight, self-hosted Git forges with a built-in Actions system whose workflow syntax closely follows GitHub Actions. That makes them a natural fit: teams keep familiar workflows, running entirely on internal infrastructure.

An air-gapped network is a submarine: once it dives, nothing can be ordered from the shops. So before diving you pack everything the crew will need: tools, spare parts, recipes, the latest map of dangers (vulnerability database). Anything new arrives only through a careful airlock, where it's checked before it comes inside.

Server and runners

; app.ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = self      ; resolve `uses:` against this server (Gitea); check your version's options

Register a runner with a token from the site, organisation or repository settings:

$ ./act_runner register --no-interactive --instance https://git.internal.example \
    --token <registration-token> --name runner-01 \
    --labels ubuntu-latest:docker://registry.internal.example/ci/ubuntu:24.04
$ ./act_runner daemon

The labels map runs-on names to images from your internal registry, so jobs never pull from Docker Hub. Forgejo's runner (forgejo-runner) follows the same pattern, and both can run on Kubernetes or VMs.

Workflows

# .forgejo/workflows/ci.yml  (or .gitea/workflows/ci.yml)
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest          # mapped to the internal image by the runner's labels
    steps:
      - uses: actions/checkout@v4   # resolved from the internal mirror
      - run: pip install --index-url https://pypi.internal.example/simple -r requirements.txt
      - run: pytest -q

Most day-to-day features work (jobs, needs, matrix, secrets, artifacts). GitHub-specific features, such as GitHub's OIDC provider, hosted runner images, the marketplace or some API-dependent actions, differ or are missing, so test each workflow and check your forge version's documentation.

Everything the pipeline needs, inside

Dependency Inside the gap
Actions Mirrored repos (pinned SHAs) in the forge
Base and tool images Internal registry (Harbor, etc.)
OS packages apt/rpm mirrors
Language packages PyPI/npm/Go/Maven proxies or mirrors (Nexus, Artifactory, devpi…)
Vulnerability data Trivy/Grype databases imported on a schedule
Signing Key-based cosign (keys in an HSM or internal KMS) or a private Sigstore deployment
Time and certificates Internal NTP and CA

The import path

Content enters through a controlled pipeline on the connected side:

  1. Fetch the exact versions needed (images by digest, packages with lockfiles, action repos at pinned SHAs).
  2. Scan and verify them (signatures, checksums, vulnerability scan).
  3. Package them with a manifest and checksums; transfer via the approved medium (a data diode or reviewed removable media).
  4. Re-verify on the inside, then publish to the internal registry and mirrors.

Treat the import as a regular, automated process (weekly, for example), not an emergency every time someone needs a new library.

Try it: a disconnected forge (lab VMs or containers)

  1. Run Forgejo or Gitea and a runner with Docker Compose on a machine (or network) you then disconnect from the internet.
  2. Before disconnecting, mirror actions/checkout into the forge, push a CI image to a local registry, and set up a small package proxy.
  3. Configure the runner labels to use the local image and the server to resolve actions locally.
  4. Disconnect, push a repo with a workflow, and make it pass using only internal sources.
  5. Import one new package through a documented "airlock" process with checksums, and record the steps.

Going deeper: operating offline

  • The freshness of mirrors and vulnerability databases is a security property: monitor their age and alert when imports stop.
  • Keep an SBOM inventory of everything inside the gap, so a new CVE announcement can be checked quickly.
  • Plan capacity and backups for the forge, the registry and the mirrors: they're now critical infrastructure.
  • For GitOps inside the gap, Argo CD or Flux simply point at the internal forge (see GitOps with Argo CD).

Recap

  • Gitea/Forgejo Actions give GitHub-Actions-style workflows fully on internal infrastructure; test each workflow for compatibility.
  • Runners map runs-on labels to internal images; uses: resolves to mirrored actions.
  • Mirror everything: images, packages, actions, vulnerability databases; sign with local trust roots.
  • Bring content in through a verified, automated import path.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.