Skip to content

actions/checkout fails against an HTTPS Gitea instance with a private CA — job containers have no way to trust the cert #39249

Description

@chris-briddock

Gitea Version

1.27.3

What happened?

act_runner: actions/checkout fails against an HTTPS Gitea instance with a private CA — job containers have no way to trust the cert

Summary

When a self-hosted Gitea instance is served over HTTPS with a certificate
signed by a private/internal CA (a very common self-hosted setup — e.g. a lab
or corporate root CA via step-ca / internal PKI), every CI job that uses
actions/checkout to clone a repo fails
because the job container's trust
store does not include the internal CA. There is no first-class mechanism in
act_runner to inject a CA certificate into job containers, so the operator
must discover and wire up a multi-env-var + bind-mount workaround for every
TLS client the job images might use.

This is the primary onboarding papercut for "self-hosted Gitea over HTTPS
with a private CA" — a core self-hosted use case.

Environment

  • gitea/act_runner:0.2.13
  • Gitea instance: https://gitea.example.internal (TLS cert signed by an internal root CA)
  • Runner host: podman (rootful)

Steps to reproduce

  1. Run a Gitea instance behind HTTPS with a cert signed by your own root CA.
  2. Register an act_runner against it (see "Related: runner registration" below
    — that has its own CA-trust problem, but assume it's solved for now).
  3. Create a workflow that uses actions/checkout:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
  1. The job fails at the checkout step:
fatal: unable to access 'https://gitea.example.internal/Foundry/my-repo.git/':
  SSL certificate problem: unable to get local issuer certificate

or, with the Go-based checkout path:

remote: ... x509: certificate signed by unknown authority

Why it fails

actions/checkout runs inside the job container (the image named by the
workflow's runs-on: / container:), not inside the act_runner container.
The job image (e.g. node:24, ubuntu:24.04) ships its own CA bundle that
trusts only public CAs. actions/checkout shells out to git fetch https://gitea.example.internal/<org>/<repo>.git, and git (via libcurl) cannot
verify the cert against the public-only bundle.

Critically, the act_runner container's trust store is not inherited by
job containers — they are a separate podman/docker process with their own
rootfs and trust store. Even if the runner itself trusts the CA (via
SSL_CERT_FILE or a rebuilt bundle), the job container starts from scratch.

Expected behavior

There should be a first-class, documented way to make job containers trust
the Gitea instance's private CA. Ideally:

  • A container.ca_cert config option (or GITEA_RUNNER_CA_CERT env var) that
    act_runner automatically mounts into job containers and wires into the
    common TLS env vars (GIT_SSL_CAINFO, NODE_EXTRA_CA_CERTS,
    CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE, SSL_CERT_FILE), so the operator
    doesn't have to know every client's env-var name, or
  • At minimum, a documented "Private CA" section in the act_runner README
    covering the workaround below.

Workaround (what we do today)

We mount the CA bundle into every job container via the runner config's
container.options and set env vars for every TLS client the job images
might use:

container:
  # Mount the CA bundle (root + intermediate) into every job container.
  options: "-v /var/lib/act_runner/ca/lab-ca-bundle.crt:/etc/ssl/certs/lab-ca-bundle.crt:ro,Z"
  envs:
    # git (libcurl backend) -- used by actions/checkout's `git fetch`.
    GIT_SSL_CAINFO: "/etc/ssl/certs/lab-ca-bundle.crt"
    # Node.js TLS (actions themselves run on node; checkout.js, etc).
    NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/lab-ca-bundle.crt"
    # curl CLI + python-requests, for ad-hoc HTTPS in job steps.
    CURL_CA_BUNDLE: "/etc/ssl/certs/lab-ca-bundle.crt"
    REQUESTS_CA_BUNDLE: "/etc/ssl/certs/lab-ca-bundle.crt"
    # Go-based tools built inside jobs default to the system store;
    # SSL_CERT_FILE overrides it (Go prefers this over the OS path).
    SSL_CERT_FILE: "/etc/ssl/certs/lab-ca-bundle.crt"

This works, but it requires the operator to know in advance that:

  1. The job container won't trust the Gitea cert.
  2. The CA must be mounted via container.options (not just set as an env var —
    the file has to physically exist in the job container).
  3. Every common TLS client has a different env var name, and missing any
    one of them means that specific tool fails opaquely.

Alternative workaround: bake the CA into a custom job image

The daemon-level injection above only reaches jobs that use the default
runner image (the one runs-on: resolves to without a workflow-level
container: block). When a workflow declares its own container: image,
act_runner does not merge the daemon's container.options /
container.envs into it — so neither the bind-mount nor the *_CAINFO env
vars reach that job, and checkout fails again with the same x509 error.

To cover that case we build a custom job image with the lab CA baked into the
system trust store, publish it to the lab registry, and expose it as a runner
label that workflows opt into.

Containerfile

FROM docker.io/library/node:24

# Bake the lab root + intermediate CA into the image's system trust store.
COPY lab-ca-bundle.crt /usr/local/share/ca-certificates/lab-internal-root-ca.crt
COPY lab-internal-intermediate-ca.crt /usr/local/share/ca-certificates/lab-internal-intermediate-ca.crt
RUN update-ca-certificates

# Node.js reads this in preference to the OS store for its own TLS.
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt

Build, save, and load into the lab registry

Because the build host could not push directly to the HTTPS registry
the registry's cert is signed by the CA we are
trying to bake in), the image was built, saved to a tarball, transferred, and
loaded on a host that already trusted the CA:

# On the build host (has the CA bundle available + can reach docker.io):
podman build -t registry.lab.internal/ci/node:24-lab \
 -f Containerfile .
podman save -o node-24-lab.tar registry.lab.internal/ci/node:24-lab

# Transfer node-24-lab.tar to the registry host:
scp node-24-lab.tar registry-vm:/tmp/

# On the registry host (CA already trusted there):
podman load -i /tmp/node-24-lab.tar
podman push registry.lab.internal/ci/node:24-lab

Runner label + workflow opt-in

The runner config maps the custom label to the pullable image:

runner:
 labels:
   - "node24-lab:docker://registry.lab.internal/ci/node:24-lab"

Workflows opt in by pinning both runs-on and container.image to it (the
label must resolve to the same pullable image the job container runs):

jobs:
 build:
   runs-on: node24-lab
   container:
     image: registry.lab.internal/ci/node:24-lab
   steps:
     - uses: actions/checkout@v4

This makes checkout "just work" for workflow-declared containers with zero
runtime injection — but it requires the operator to maintain and rebuild a
custom image per base image they want to support, and every workflow author
must know to opt into the -lab label. A first-class container.ca_cert
knob would remove both of these burdens.

Related: runner registration also needs the CA (secondary issue)

Separately from checkout, the act_runner container itself must trust the CA
to register (act_runner register --instance https://...) and to poll for
jobs. The gitea/act_runner:0.2.13 image is a distroless-ish Alpine build
that ships no update-ca-certificates (or ca-certificates beyond a static
bundle), so mounting the CA into the runner container is not enough on its
own — Go's crypto/x509 doesn't auto-pick-up arbitrary files on Alpine
unless they're merged into /etc/ssl/certs/ca-certificates.crt or
SSL_CERT_FILE is set.

Workaround for registration

Override the entrypoint to sh, mount the lab certs, concatenate the image's
existing CA bundle with the internal root + intermediate into a single file,
and point Go at it via SSL_CERT_FILE:

entrypoint: sh
command: >
  -c '
    cat /etc/ssl/certs/ca-certificates.crt
        /usr/local/share/ca-certificates/internal-root-ca.crt
        /usr/local/share/ca-certificates/internal-intermediate-ca.crt
    > /tmp/lab-ca-bundle.crt;
    act_runner register --no-interactive
      --instance https://gitea.example.internal
      --token ${RUNNER_TOKEN}
      --name my-runner
      --config /etc/act_runner/config.yaml || true;
    exec act_runner daemon --config /etc/act_runner/config.yaml
  '
environment:
  SSL_CERT_FILE: /tmp/lab-ca-bundle.crt

The || true on register is because re-running register on an existing
.runner state file is a no-op (the runner validates and proceeds to
daemon), so the container restarts cleanly.

A bundled update-ca-certificates (or a tiny shim that cats files from
/usr/local/share/ca-certificates/ into /etc/ssl/certs/ca-certificates.crt)
in the act_runner image would make the runner-side half "just work" with a
mounted cert — but the job-side trust problem (the primary issue above)
is the bigger usability gap and needs its own solution.

How are you running Gitea?

Podman Quadlet - the repo is below.

https://github.com/chris-briddock/homelab-iac

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    issue/needs-feedbackFor bugs, we need more details. For features, the feature must be described in more detailtype/bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions