Skip to content

VCS Connector

A VCS Connector lets Zenfra reach a Git server that has no inbound path from the internet: GitHub Enterprise Server, self-managed GitLab, Bitbucket Data Center or Azure DevOps Server. You run the connector inside your network. It dials out to Zenfra and holds that connection open; Zenfra never connects in, so no firewall rule, VPN or public endpoint is needed. In the default credential mode your VCS access token stays on the connector's disk and never reaches Zenfra.

This page describes connector 0.2.0. The full manual, the security model and the optional modes live in the connector repository at that release:

Version note. The recipes shown in the app's Run an instance step currently name connector 0.1.0. Use 0.2.0 instead, as on this page: the image tag, the binary archive name and the Helm image.tag all change. Version 0.1.0 lacks the allowlist visibility behaviour described below.

Before you start

  • Outbound HTTPS from wherever the connector runs to api.zenfra.cloud on port 443. A corporate proxy is fine, see Optional extras.
  • Network reach from the connector to your VCS.
  • A VCS personal access token with read scopes (read_api and read_repository for GitLab; the equivalent read scopes elsewhere). It stays on the connector.
  • Docker, a Linux host, or a Kubernetes cluster for the connector. It idles under 50 MiB of memory.
  • The Admin role in Zenfra.

Step 1: Create the connector

From Integrations > Private network access > Create connector (or View all connectors > Create connector). One connector per VCS endpoint. Name, vendor and endpoint are fixed once the connector is created.

  • Name: unique within your organization, for example gitlab-internal.
  • Description: optional, for example where it runs and who owns it.
  • Vendor: GitLab, GitHub Enterprise, Bitbucket Data Center or Azure DevOps Server.
  • Endpoint: the base URL of your VCS. GitLab: https://gitlab.internal. GitHub Enterprise: https://ghe.example.com (the API is served from /api/v3 automatically). Bitbucket: the base URL of Bitbucket Data Center. Azure DevOps Server: the collection URL, for example https://tfs.internal/DefaultCollection, not the server root.

Step 2: Save the bootstrap token

The next step shows the connector's bootstrap token once. It is stored hashed and cannot be retrieved later. Copy it into your secret manager, tick I have stored this token and continue. If you lose it, Rotate key on the connector page issues a new one; the old token keeps working for 1 hour so you can redeploy.

Step 3: Run an instance

The Run an instance step builds a recipe from your choices. Run it inside your network.

Project scope. Choose Allowed projects (default) and list the projects this connector may serve, or All projects. A request for a project outside the list is denied at the connector, even if the operation itself is allowed. Write each entry in the form the vendor's own API uses:

Vendor Entry form Example
GitLab Numeric project ID (Zenfra addresses GitLab projects by ID) 42,1337
GitHub Enterprise owner/repo acme/infra
Bitbucket Data Center PROJECTKEY/repo-slug INFRA/terraform
Azure DevOps Server project/repo Platform/infra

A project missing from the list does not affect the connector's health: with connector 0.2.0 and later it is simply absent after Sync Repositories (see Allowlist visibility below), and any request for it is denied at the connector.

The recipes below are for GitLab at https://gitlab.internal serving projects 42 and 1337. Run them in a root shell (sudo -i): the setup block creates root-owned files, and the later cat and kubectl create secret --from-file reads must be able to open them. With All projects, the allowed-projects line becomes ZENFRA_VCS_CONNECTOR_ALL_PROJECTS=true (container), --all-projects (binary) or --set connector.allProjects=true (Helm).

Container

Create the secret files before docker run: if a mount source does not exist, Docker silently creates it as a directory and the connector fails with secret file is a directory.

sudo mkdir -p /etc/zenfra
printf '%s' '<bootstrap token from the previous step>' | sudo tee /etc/zenfra/bootstrap-token >/dev/null
printf '%s' '<your GitLab access token>' | sudo tee /etc/zenfra/vcs-token >/dev/null
sudo chmod 0600 /etc/zenfra/bootstrap-token
# 0644: the container runs as UID 65532 and cannot read a root-owned 0600 file
sudo chmod 0644 /etc/zenfra/vcs-token

sudo mkdir -p /var/lib/zenfra-connector && sudo chown 65532:65532 /var/lib/zenfra-connector

ZENFRA_VCS_CONNECTOR_BOOTSTRAP_TOKEN="$(cat /etc/zenfra/bootstrap-token)" docker run -d --restart=unless-stopped --name zenfra-vcs-connector \
  -v /etc/zenfra/vcs-token:/secrets/vcs-token:ro \
  -v /var/lib/zenfra-connector:/state \
  -e ZENFRA_VCS_CONNECTOR_GATEWAY_URL='https://api.zenfra.cloud' \
  -e ZENFRA_VCS_CONNECTOR_BOOTSTRAP_TOKEN \
  -e ZENFRA_VCS_CONNECTOR_ENDPOINT='https://gitlab.internal' \
  -e ZENFRA_VCS_CONNECTOR_VENDOR='gitlab' \
  -e ZENFRA_VCS_CONNECTOR_ALLOWED_PROJECTS='42,1337' \
  -e ZENFRA_VCS_CONNECTOR_SECRET_FILE=/secrets/vcs-token \
  -e ZENFRA_VCS_CONNECTOR_ENROLLMENT_KEY_FILE=/state/enrollment-key \
  -e ZENFRA_VCS_CONNECTOR_INSTANCE_KEY="$(hostname)" \
  ghcr.io/zenfracloud/zenfra-vcs-connector:0.2.0

If other users share the host, prefer sudo chown 65532:65532 /etc/zenfra/vcs-token && sudo chmod 0600 /etc/zenfra/vcs-token over mode 0644; the container reads the file as UID 65532 either way.

No -p and no Docker socket: the connector needs neither. The state volume and ENROLLMENT_KEY_FILE persist this instance's own enrollment key, so restarts re-authenticate as the same instance instead of re-registering with the fleet-wide bootstrap token. INSTANCE_KEY pins a stable identity; left unset it defaults to the container ID, which changes on every recreate.

Binary

Download zenfra-vcs-connector_0.2.0_linux_amd64.tar.gz (or _arm64) and SHA256SUMS from the v0.2.0 release, then, after the same secret-file setup as above:

sha256sum -c SHA256SUMS --ignore-missing
tar xzf zenfra-vcs-connector_0.2.0_linux_amd64.tar.gz

ZENFRA_VCS_CONNECTOR_BOOTSTRAP_TOKEN="$(cat /etc/zenfra/bootstrap-token)" ./zenfra-vcs-connector \
  --gateway-url 'https://api.zenfra.cloud' \
  --endpoint 'https://gitlab.internal' \
  --vendor 'gitlab' \
  --allowed-projects '42,1337' \
  --secret-file /etc/zenfra/vcs-token \
  --enrollment-key-file /var/lib/zenfra-connector/enrollment-key

For a systemd unit, run it as a dedicated service user that owns /var/lib/zenfra-connector and can read /etc/zenfra/vcs-token (the chown 65532 in the setup block is for the container image, not for a binary install), with Restart=on-failure. Exit code 2 means the configuration is wrong and retrying will not help; 1 is a runtime failure.

Kubernetes (Helm)

No Helm repository is published yet, so install the chart from a checkout of the connector repository at the release tag. The chart never templates secret material: you create the Secret, the chart mounts it.

git clone --branch v0.2.0 https://github.com/ZenfraCloud/zenfra-vcs-connector.git
cd zenfra-vcs-connector

kubectl create secret generic zenfra-vcs-connector \
  --from-file=bootstrap-token=/etc/zenfra/bootstrap-token \
  --from-file=credential=/etc/zenfra/vcs-token

helm install vcs-connector ./deploy/helm/zenfra-vcs-connector \
  --set image.tag=0.2.0 \
  --set 'connector.gatewayUrl=https://api.zenfra.cloud' \
  --set 'connector.endpoint=https://gitlab.internal' \
  --set 'connector.vendor=gitlab' \
  --set 'connector.allowedProjects={42,1337}' \
  --set secret.name=zenfra-vcs-connector

Keep --set image.tag=0.2.0: at v0.2.0 the chart's own version and appVersion still read 0.1.0, and without the override the deployment falls back to the 0.1.0 image. replicaCount defaults to 2; instances are independent and active-active, so a rolling upgrade never drops the tunnel entirely.

Confirm it connected

The wizard waits for the first instance (Waiting for the first instance to connect…) and then shows Connected — {n} instance(s), {n} stream(s). If nothing arrives within two minutes, read the connector's logs: a misconfiguration exits with code 2 and names the flag and environment variable at fault. Done becomes available once the connector is healthy.

Step 4: Bind an integration

Click Bind an integration (or add one later from the provider page with Hosting inside your network) and pick the connector. The integration has no base URL and no token of its own: the endpoint comes from the connector, and the credential stays on it. On the integration's detail page, Verify connection makes a tunneled "who am I" call, Sync Repositories discovers repositories, and each repository's Enabled switch makes it selectable for stacks. From here on, stacks use the repository exactly like one on a public provider; the worker needs no connector configuration and no path to your VCS.

Bitbucket Data Center and Azure DevOps Server are connector-only; GitLab and GitHub Enterprise Server can use either hosting form.

Health states

The connector's health is shown on the hub tile, the Connectors list, the connector page and every integration bound to it.

Health Meaning What to do
Healthy Both lanes live: interactive (API calls) and bulk (archive downloads). Nothing.
Degraded One lane live. Interactive-only: browsing works, archive downloads, and therefore runs, do not. Check the connector logs for a failing dial; the bulk lane is a separate connection and can fail alone.
Offline No live streams. Binding, rotating and revoking still work while offline. See The connector will not connect.
Policy mismatch An instance reaches Zenfra but its vendor, endpoint or allowlist fingerprint is not the one pinned on this connector. See policy_mismatch.
Unknown Zenfra-side misconfiguration. Contact support@zenfra.cloud.

Managing a connector

  • Instances: every instance that registered, with its version, status (Registered, Enrolled, Revoked) and last-seen time. Revoke closes an instance's streams immediately and it can never register again, not with its own key and not with the bootstrap token; other instances keep running.
  • Rotate key: issues a new bootstrap token; the current one keeps working for 1 hour, so redeploy your instances within that window. Instances that already registered keep their own keys. If the old token leaked, also revoke every instance you do not recognise: rotating alone does not evict them.
  • Delete connector: refused while integrations use it ({name} is in use); remove those integrations first. Deleting an integration is itself destructive: its stacks lose their source, and it cannot be re-pointed at another connector.
  • Allowlist visibility (connector 0.2.0 and later): the connector's allowed projects govern what Zenfra shows, not only what it can fetch. Repository sync skips repositories outside the list and prunes ones that fell out of it, and pointing a stack at such a repository is refused with repo_not_on_connector_allowlist. A pre-0.2.0 connector advertises nothing and Zenfra filters nothing.

Optional extras

Behind a proxy. The standard HTTPS_PROXY, HTTP_PROXY and NO_PROXY variables are honoured on every leg. Only explicit proxy URLs are supported (basic auth is fine); PAC/WPAD auto-configuration and Kerberos/NTLM proxy authentication are not, so use an explicit proxy URL or allow api.zenfra.cloud through unauthenticated. Keep your internal VCS off the proxy with NO_PROXY=gitlab.internal. Helm: proxy.httpsProxy, proxy.httpProxy, proxy.noProxy.

Private CA. The two legs have different trust: Zenfra is publicly signed, your VCS usually is not. --ca-bundle (env ZENFRA_VCS_CONNECTOR_CA_BUNDLE) sets the bundle for the Zenfra leg, for example an egress appliance that re-signs TLS; --upstream-ca-bundle (ZENFRA_VCS_CONNECTOR_UPSTREAM_CA_BUNDLE) sets the bundle for the VCS leg. In the container recipe, mount the PEM and add -v /etc/zenfra/vcs-ca.pem:/secrets/vcs-ca.pem:ro and -e ZENFRA_VCS_CONNECTOR_UPSTREAM_CA_BUNDLE=/secrets/vcs-ca.pem. Helm: caBundle.configMapName plus caBundle.gatewayKey and/or caBundle.upstreamKey. An unreadable bundle fails at startup, not per request.

Enrollment key file. --enrollment-key-file (already in the recipes above) makes the instance use the bootstrap token exactly once, then persist its own per-instance key. After that, revoking the instance holds even against someone who stole the bootstrap token. Mount a writable volume for it; without the flag, each restart re-registers with the bootstrap token. Helm enables it by default with a per-pod emptyDir; never point two replicas at the same volume. A connector holds at most 200 live instance records; records for instances that stop reporting are reclaimed 30 days after their last contact, and a host that comes back later re-enrols with its bootstrap token by itself.

Metrics. --metrics-addr 0.0.0.0:9090 exposes Prometheus text-format metrics (tunnel streams, connects, requests, errors, request duration, start time). Off by default, because opening a listener inside your network is your call. Helm: metrics.enabled, metrics.port.

Push triggers. --webhook-addr 0.0.0.0:8080 --webhook-secret-file /etc/zenfra/webhook-secret, both required together. Point your VCS's webhook at http://<connector-host>:8080/webhook; the connector verifies the secret locally and relays only verified deliveries to Zenfra (deliveries over 64 KiB are refused with 413). Answers: 202 accepted, 401 refused, 503 redeliver, 204 refused by Zenfra. Zenfra currently acts on GitLab push events only; events from other vendors are refused by Zenfra. The Helm chart does not template the webhook flags or a Service for the listener yet; run the connector as a container or a binary to use push triggers.

Troubleshooting

The connector exits immediately with code 2

The configuration is wrong and no amount of retrying fixes it. The message names both the flag and the environment variable. Common causes: --allowed-projects missing without --all-projects; --secret-file missing (or set in control_plane credential mode); an unparseable --metrics-addr; an unreadable CA bundle; --policy-mode blocklist without --all-projects.

The connector will not connect

Read its stderr first; it says what it is doing.

Symptom Cause Fix
401 on register, then exit Bootstrap token wrong, or rotated out past its grace window Rotate the key and redeploy with the new token
401 instance_unknown, then re-enrolling with the bootstrap token The host was offline longer than the 30-day record TTL, so its enrollment key names a reclaimed instance None: the connector re-enrols with the bootstrap token it still has. If the token was removed after enrolment, the connector exits 2 instead; start it once with the token again
403 instance_revoked This instance was revoked Revocation is permanent for that instance key: delete the enrollment key file and start the connector under a new --instance-key
401 token_superseded, then reconnects A peer stream refreshed the token every stream shares None: the stream re-mints and reconnects on its own
403 on the tunnel upgrade Fingerprint mismatch See policy_mismatch
Reconnect loop, no HTTP status No egress to api.zenfra.cloud, or a proxy is not configured Check HTTPS_PROXY; confirm port 443 egress
TLS error dialing the gateway Your egress appliance re-signs TLS and its root is not trusted Set --ca-bundle to your corporate root
Connected, then drops every few seconds An idle timeout on a proxy below Zenfra's 30 s keepalive Raise the proxy's read timeout above 30 s

Reconnects are automatic with jittered backoff from 1 s to a 30 s cap, indefinitely. A connection that has been up for 45 minutes is closed on purpose so that reconnecting re-authenticates; that is not a fault.

policy_mismatch

An instance announced a vendor, endpoint or allowlist fingerprint that is not the one pinned on the connector by its first-ever instance. Almost always one of:

  • The connector was upgraded to a version with a different allowlist. The fingerprint is pinned by the first instance ever admitted and there is no way to re-pin it, so rolling forward does not clear the mismatch. Either roll back to the version that set the pin, or delete the connector and create a new one (new bootstrap token, rebind the integration, re-sync repositories). Release notes call out any version that changes the allowlist.
  • --endpoint differs: http versus https, or a different host. Trailing slashes and host case are canonicalized away, but a genuinely different host is a genuinely different connector.
  • --vendor differs.
  • One instance runs --policy-mode blocklist and the others do not. Blocklist mode changes the fingerprint deliberately, so it cannot quietly join an allowlist fleet.

A mismatched instance is refused before the connection is upgraded, so it never becomes a stream. That is why the state is reported separately rather than looking like plain Offline.

For failures of individual VCS calls (connector_offline, policy_denied, upstream_*, outcome_unknown and others) and for finding a request in the connector's own logs, see the setup guide's Troubleshooting section.