byeCloud: GitLab with Docker and Traefik

published on in category byeCloud , Tags: byecloud selfhosted docker gitlab traefik

For some months now I’m running a private GitLab server. I really enjoy using it, especially with all the great features like the Docker Container Registry and GitLab Pages to host static pages, even with own domains. Normally I would prefer a more lightweight solution, such as Gitea but GitLab has so many advantages that, at least for me, this is currently the only way to go. However, it felt tedious setting it up, even with Docker. You have to configure GitLab to serve stuff using plain HTTP, provide different ports for different apps to be able to create own vhosts in the reverse proxy and so on. So I decided to quickly write up what I did to get it working. Maybe I’m wrong and there’s a much easier way to do it but I couldn’t find it. Additionally, in the meanwhile I switched from Caddy as a reverse proxy to Traefik since it can attach directly to the Docker daemon, listens to changes in the domain configuration, request new HTTPS certificates on the fly while new containers are spawned and - best of all - I don’t need a separate configuration file. So the guide this time is still using Docker and docker-compose, but Traefik instead of Caddy. But you can basically use any reverse proxy to set it up, like nginx-proxy.

Compose setup

With this configuration I am able to run GitLab, the Container Registry and GitLab Pages on different subdomains with one IP address, each with it’s own certificate off-loaded by Traefik. Additionally I have one GitLab Runner to run CI jobs from GitLab (actually I have multiple on different hosts now).

First off, here’s my docker-compose.yml with inline comments explaining all the configuration details:

version: '3'
services:
  traefik:
    image: traefik:latest
    restart: always
    command: --docker
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./traefik.toml:/etc/traefik/traefik.toml
      - ./acme.json:/acme.json

  gitlab:
    image: gitlab/gitlab-ce:latest
    restart: always
    hostname: gitlab.example.com
    # I had problems with the health check. Sometimes it reported unhealthyness and therefore Traefik removed 
    # the container, so I turned it off. Maybe it works by now.
    healthcheck:
      disable: true
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        # External URL as it will be seen by GitLab users, so with HTTPS, even if GitLab itself only serves HTTP
        external_url 'https://gitlab.example.com'

        # Disable HTTPS and serve plain HTTP. The rest will be handled by Traefik
        nginx['listen_port'] = 80
        nginx['listen_https'] = false

        # I had some issues and therefore disabled HTTP/2, but should normally work
        nginx['http2_enabled'] = false

        # Pass headers to GitLab, $$ escapes the literal $
        nginx['proxy_set_headers'] = {
          "Host" => "$$http_host",
          "X-Real-IP" => "$$remote_addr",
          "X-Forwarded-For" => "$$proxy_add_x_forwarded_for",
          "X-Forwarded-Proto" => "https",
          "X-Forwarded-Ssl" => "on"
        }

        # Define SSH port for git+ssh, can also be changed as you like
        gitlab_rails['gitlab_shell_ssh_port'] = 22

        # External URL for the registry as seen by GitLab users with HTTPS even if Gitlab itself only serves HTTP
        registry_external_url 'https://registry.gitlab.example.com'

        # Disable HTTPS and set custom port for the service
        registry_nginx['listen_port'] = 5100
        registry_nginx['listen_https'] = false

        # Pass headers
        registry_nginx['proxy_set_headers'] = {
          "Host" => "$$http_host",
          "X-Real-IP" => "$$remote_addr",
          "X-Forwarded-For" => "$$proxy_add_x_forwarded_for",
          "X-Forwarded-Proto" => "https",
          "X-Forwarded-Ssl" => "on"
        }

        # External URL for Pages hosting as seen by GitLab users with HTTPS even if Gitlab itself only serves HTTP
        pages_external_url 'https://pages.gitlab.example.com'

        # Disable HTTPS and set custom port for the service
        pages_nginx['listen_port'] = 5200
        pages_nginx['listen_https'] = false

        # Pass headers
        pages_nginx['proxy_set_headers'] = {
          "Host" => "$$http_host",
          "X-Real-IP" => "$$remote_addr",
          "X-Forwarded-For" => "$$proxy_add_x_forwarded_for",
          "X-Forwarded-Proto" => "https",
          "X-Forwarded-Ssl" => "on"
        }

        # Seems like when you use Docker data volumes, you need this, otherwise it shows this in the log:
        # "Failed to bind mount /gitlab-data/shared/pages on /tmp/gitlab-pages-xyz/pages. operation not permitted"
        gitlab_pages['inplace_chroot'] = true
        
        # Tell GitLab to use an external HTTP server, like Traefik in our case, to handle custom domains.
        # The documentation says that you'd need to point an additional IP address here that you want to use but I 
        # figured out that just using the GitLab container name works as well, so no need to purchase an additional
        # domain :-)
        gitlab_pages['external_http'] = ['gitlab:5201']
    volumes:
      - gitlab-config:/etc/gitlab
      - gitlab-logs:/var/log/gitlab
      - gitlab-data:/var/opt/gitlab
    ports:
      # Feel free to map this to a different port if that one is in use already
      - "22:22"
    labels:
      - traefik.enable=true
      # Host settings for GitLab itself
      - traefik.gitlab.frontend.rule=Host:gitlab.example.com
      - traefik.gitlab.port=80
      # Host settings for the registry
      - traefik.registry.frontend.rule=Host:registry.gitlab.example.com
      - traefik.registry.port=5100
      # Host settings for GitLab pages. Since I don't have a wildcard certificate, I list every domain on it's own here
      - traefik.pages.frontend.rule=Host:pages.gitlab.example.com,username.pages.gitlab.example.com
      - traefik.pages.port=5201

  gitlab-runner:
    image: gitlab/gitlab-runner:alpine
    restart: always
    volumes:
      # Mount Docker socket for dind
      - /var/run/docker.sock:/var/run/docker.sock
      # We need to slightly modify the runner config, so mount it here and just create an empty file for the beginning
      # Make sure that the permissions are correct so that the container can write to it
      - ./runner.toml:/etc/gitlab-runner/config.toml

volumes:
  gitlab-config:
  gitlab-logs:
  gitlab-data:
  gitlab-runner-data:

Here’s my Traefik configuration, also with comments, called traefik.toml in my case:

# Show Docker events for now
logLevel = "INFO"
defaultEntryPoints = ["https", "http"]

# Force HTTPS
[entryPoints]
  [entryPoints.http]
  address = ":80"
    [entryPoints.http.redirect]
    entryPoint = "https"
  [entryPoints.https]
  address = ":443"
    [entryPoints.https.tls]

[acme]
email = "webmaster@example.com"
# Store certificates in /acme.json
storage = "acme.json"
acmeLogging = true
onHostRule = true
onDemand = false
# Use HTTP challenge as my DNS provider does not support DNS challenge
entryPoint = "https"
  [acme.httpChallenge]
  entryPoint = "http"

[docker]
endpoint = "unix:///var/run/docker.sock"
watch = true
# Only expose services that are enabled explicitly
exposedbydefault = false

Connecting the Runner

After starting everything and setting a password for the GitLab administrator account, you can register your GitLab runner. Run the register command inside the container:

docker-compose run --rm gitlab-runner register

Choose “docker” as a runner type. You will be asked for your GitLab URL, which would be https://gitlab.example.com in our example. The runner token can be obtained from the GitLab admin area at Overview -> Runners. After the register command is done it will not work directly. Since we use Docker in Docker (our runner runs inside a Docker container and is able to use Docker on it’s own), we need to set our runner to privileged mode. Open the runner.toml file now and add the privileged = true line to your runner:

concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "example runner"
  url = "https://gitlab.example.com/"
  token = "123"
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "alpine:latest"
    # Add this line
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache"]
    shm_size = 0
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]

Afterwards just restart the container using

docker-compose restart gitlab-runner

and you should be good to go.

Setting up Pages

In the label traefik.pages.frontend.rule of my docker-compose.yml you can see that I defined a list of domains I want to use with GitLab Pages. Normally this is done using a wildcard DNS but since my DNS provider does not support Let’s Encrypt DNS Challenge and I want TLS certificates for all my pages I cannot use a wildcard subdomain. Workaround: define every domain manually. But I only use two or three domains, so it’s fine.

Afterwards, just create a repository and use the CI to build your page to static HTML. Using an artifact it can be automatically deployed to GitLab pages. In the Settings of your Project you will be able to define a custom domain for your page that needs to be pointed to port 5201 of your GitLab container, just like I did it with Traefik in the docker-compose.yml above.

At https://gitlab.com/pages/plain-html you can find an example on how to serve a plain HTML page. Another example is Hugo, which I use for my blog. Here’s my gitlab-ci.yml:

image: dettmering/hugo-build

pages:
  script:
  # Build the page
  - hugo --config config_prod.toml
  artifacts:
    paths:
    # Public the public folder to GitLab pages
    - public
  only:
  - master

As an living example, this page is actually served through GitLab pages :-)