Stable

Run GitLab Runner with the Docker executor

Learn how to run a private GitLab Runner with the Docker executor, keep CI traffic separate from browser access and troubleshoot Package Registry failures.

Steps

Follow these in order.

  1. 01Separate browser and CI trafficKeep browser access protected by BusinessProxy while GitLab Runner and job containers use a private machine-to-machine route.
  2. 02Start and register the RunnerPersist the configuration, pin the image version, use a dedicated Docker network and enter the token interactively.
  3. 03Provide internal HTTPSAccept HTTPS on an internal TLS proxy and forward requests to GitLab over HTTP without exposing another host port.
  4. 04Switch without interrupting jobsValidate the TLS proxy, move the canonical Docker alias, update every runner entry and preserve a rollback copy.
  5. 05Verify and troubleshoot publishingDistinguish DNS and TLS failures from GitLab authorization errors, then verify the setup with a real Package Registry job.

Reference

Implementation details for setup and review.

When to use this guideUse this guide when a self-hosted GitLab instance is published to browser users through BusinessProxy App Gateway, while GitLab Runner uses the Docker executor on the same private host or Docker network. Browser access and CI traffic have different authentication models, so CI traffic must not depend on an interactive BusinessProxy session.
  • Keep GitLab external_url set to the canonical HTTPS address, for example https://gitlab.example.com.
  • The GitLab container may continue listening on HTTP port 80 inside the private Docker network.
  • GitLab Runner polling, repository cloning and Package Registry requests use private internal HTTPS and do not depend on browser MFA or an App Gateway session.
Traffic and trust boundaryUse one canonical hostname but two controlled paths. Requests from external browsers pass through BusinessProxy and its outbound connector. GitLab Runner and job containers resolve the same hostname to an internal TLS proxy, which forwards requests to the GitLab HTTP service.
Browser -> BusinessProxy App Gateway -> GitLab HTTP :80
Runner/job -> internal TLS sidecar :443 -> GitLab HTTP :80
  • Only the sidecar owns the gitlab.example.com alias inside the Docker network.
  • GitLab keeps a separate service alias such as gitlab so the sidecar can reach http://gitlab:80.
  • Do not publish the sidecar with -p or ports; it is an internal CI endpoint, not another public GitLab entry point.
1. Create the network and start RunnerUse a dedicated Docker network and persist /etc/gitlab-runner. Pin an approved Runner image version instead of a floating latest tag.
docker network create gitlab-internal
docker network connect --alias gitlab gitlab-internal gitlab

docker run -d --name gitlab-runner \
  --restart unless-stopped \
  --network gitlab-internal \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:alpine-v<tested-version>
  • Mounting the Docker socket gives Runner host-level control. Use a dedicated host, assign the runner only to trusted projects and restrict it to protected branches and tags.
  • Set a restart policy and monitor free disk space because images, helper layers and build caches accumulate on the host.
  • Do not run docker network create or docker network connect if the network or attachment already exists. Inspect the current aliases first and preserve unrelated ones.
2. Add the internal TLS sidecarThe TLS proxy must present a certificate valid for the canonical hostname and forward requests to GitLab's HTTP service over the private Docker network. Mount certificate and configuration files read-only.
events {}
http {
  upstream gitlab_backend { server gitlab:80; }
  server {
    listen 443 ssl;
    server_name gitlab.example.com;
    ssl_certificate /certs/fullchain.pem;
    ssl_certificate_key /certs/privkey.pem;
    client_max_body_size 0;

    location / {
      proxy_pass http://gitlab_backend;
      proxy_http_version 1.1;
      proxy_set_header Host gitlab.example.com;
      proxy_set_header X-Forwarded-Proto https;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_request_buffering off;
      proxy_buffering off;
      proxy_read_timeout 3600s;
      proxy_send_timeout 3600s;
    }
  }
}
  • Authorization, JOB-TOKEN and DEPLOY-TOKEN headers must reach GitLab unchanged. Do not replace them in the sidecar configuration.
  • Validate the nginx configuration before reloading nginx after a certificate update. Do not use an expired certificate or a certificate for another hostname.
3. Start the TLS proxy without a public portMount the directory containing the certificate rather than copying private key material into the image. For initial validation, start with a temporary internal alias; move the canonical alias to this container only after the check succeeds.
docker run -d --name gitlab-internal-tls \
  --restart unless-stopped \
  --network gitlab-internal \
  --network-alias gitlab-internal-tls \
  -v /srv/gitlab-internal-tls/nginx.conf:/etc/nginx/nginx.conf:ro \
  -v /secure/certs/gitlab.example.com:/certs:ro \
  nginx:1.27-alpine
  • Do not add -p 443:443. Runner and job containers reach the sidecar through gitlab-internal.
  • Automate nginx -t and nginx -s reload after certificate renewal, and alert before certificate expiry.
4. Perform a safe alias cutoverDo not change Docker aliases while a job is active. Validate the TLS proxy by IP first, then remove the canonical alias from the HTTP-only GitLab container and assign it only to the TLS proxy. Preserve the previous alias assignments for rollback.
curl --resolve gitlab.example.com:443:<sidecar-ip> \
  https://gitlab.example.com/users/sign_in

docker network inspect gitlab-internal
  • Do not add -k to the preflight: the test must verify hostname and certificate trust.
  • After moving the alias, restart the TLS proxy so it resolves the GitLab address again with the final network configuration.
  • Expected GitLab responses are HTML 200, a normal redirect, or 401 for a protected API path, not a BusinessProxy error page.
5. Register Runner without exposing the tokenCreate the runner in GitLab only after internal HTTPS is ready, then run interactive registration so the authentication token is not copied into documentation, shell history or process logs.
docker exec -it gitlab-runner gitlab-runner register \
  --url https://gitlab.example.com \
  --executor docker \
  --docker-image node:22-alpine \
  --description private-docker-runner
  • Enter the authentication token only at the prompt and store the resulting config.toml with restricted permissions.
  • Do not print authentication tokens or the full config.toml in CI logs or support tickets.
6. Configure every runner entryRunner polling and repository cloning must use the canonical HTTPS address. Docker network_mode is also required because the executor creates separate job and helper containers.
[[runners]]
  url = "https://gitlab.example.com"
  clone_url = "https://gitlab.example.com"
  executor = "docker"

  [runners.docker]
    image = "node:22-alpine"
    network_mode = "gitlab-internal"
  • The real config also contains a generated runner token; it is intentionally omitted from this example.
  • Update all entries in config.toml. One stale entry can continue polling or cloning through the wrong route.
7. Verify from Runner and a job containerTest DNS and TLS from both execution contexts. A successful check from the Runner container alone is insufficient because Docker executor jobs are separate containers.
docker exec gitlab-runner getent hosts gitlab.example.com
docker exec gitlab-runner gitlab-runner verify

docker run --rm --network gitlab-internal curlimages/curl:<tested-version> \
  -I https://gitlab.example.com/users/sign_in
  • The hostname must resolve only to the sidecar address inside gitlab-internal.
  • Run a real pipeline that clones a repository and publishes or downloads a package. Treat only a successful job as end-to-end proof.
ECONNREFUSED on a private address and port 443An error such as connect ECONNREFUSED 172.x.x.x:443 normally means Docker DNS resolved gitlab.example.com directly to the GitLab container, while embedded GitLab NGINX listens only on HTTP port 80. The request did not reach BusinessProxy or the public edge.
  • Inspect aliases for every container in gitlab-internal and remove the canonical alias from the HTTP-only GitLab container.
  • Assign the canonical alias to the TLS sidecar and restart Runner after changing config.toml.
  • Do not change external_url to HTTP and do not disable certificate validation to hide the routing error.
Other Runner and Package Registry symptoms
  • x509 or hostname mismatch: fix the certificate SAN, trust chain and mounted certificate; do not disable TLS verification.
  • 401 Unauthorized: DNS and TLS are working; check CI_JOB_TOKEN, deploy token, project permissions and Package Registry policy.
  • 404 Not Found: verify the project ID, package path, endpoint type and whether the registry is enabled for the project.
  • POST /api/v4/jobs/request returns 204: Runner is healthy but GitLab has no matching pending job.
  • Runner receives a job but clone or npm publish fails: confirm that helper and job containers use gitlab-internal, not only the Runner container.
Security and operations checklistThe internal route is a machine-to-machine endpoint for trusted CI workloads. It does not weaken BusinessProxy policy for human browser access and must not become a second public GitLab endpoint.
  • Keep the sidecar internal-only, mount keys read-only and reload only after nginx -t succeeds.
  • Restrict Runner to trusted projects, protect release jobs and rotate a runner token after suspected disclosure.
  • Monitor certificate expiry, Runner polling errors, failed package requests and host disk consumption.
  • Keep a restricted administrative recovery path and a tested copy of the previous runner configuration.