> ## Documentation Index
> Fetch the complete documentation index at: https://docs.varios-ai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Certificates in Traefik

> Provide TLS certificates from your own CA in Traefik instead of using Let's Encrypt

For on-premise installations, VARIOS AI is served through **Traefik**. By default, Traefik requests the TLS certificate for your domain automatically from **Let's Encrypt** (certificate resolver `le`). This requires your instance to be reachable from the internet on ports 80/443.

In many enterprise environments this is undesirable or impossible. Instead of Let's Encrypt, you can provide Traefik with your **own certificate** — for example a wildcard certificate, a certificate from your internal CA, or a commercially purchased certificate.

<Note>
  This page covers the certificate Traefik uses to secure **incoming** HTTPS connections from users. If VARIOS AI needs to reach **outbound** services that use self-signed certificates, you need a CA bundle inside the container instead — see [Custom TLS Certificates](/en/extended-support/security/custom-tls-certificates).
</Note>

***

## Prerequisites

<Steps>
  <Step title="Certificate and private key in PEM format">
    Traefik needs two files: the certificate chain (`.crt`/`.pem`) and the private key (`.key`). Both must be in **PEM format** (a text file starting with `-----BEGIN CERTIFICATE-----` or `-----BEGIN PRIVATE KEY-----`).
  </Step>

  <Step title="Complete certificate chain">
    The certificate file must contain the server certificate **and all intermediate certificates** — the server certificate first, followed by the intermediates. Without the chain, browsers and API clients report a certificate error.
  </Step>

  <Step title="Unencrypted private key">
    Traefik cannot unlock passphrase-protected keys. The key must be stored without a passphrase.
  </Step>

  <Step title="Matching common name or SAN">
    The certificate must be issued for the domain configured as `PROJECT_DOMAIN` in your `.env` (or list it as a *Subject Alternative Name*).
  </Step>
</Steps>

<Tip>
  Check format and validity up front:

  ```bash theme={null}
  openssl x509 -in varios.crt -noout -subject -issuer -dates -ext subjectAltName
  openssl pkey -in varios.key -check -noout
  ```

  Certificate and key belong together only if both checksums are identical:

  ```bash theme={null}
  openssl x509 -in varios.crt -noout -modulus | openssl md5
  openssl pkey -in varios.key -noout -modulus | openssl md5
  ```
</Tip>

***

## How Traefik obtains certificates

Traefik knows two ways to obtain a certificate for a router:

| Method                   | Configuration                                                            | Use case                                                |
| ------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------- |
| **ACME / Let's Encrypt** | Label `traefik.http.routers.<name>.tls.certresolver=le` on the container | Default for instances reachable from the internet       |
| **File provider**        | YAML file in the `./traefik/dynamic` directory                           | Custom certificates, internal CA, wildcard certificates |

The `docker-compose.yml` shipped for on-premise installations is already prepared for both. The Traefik service starts with the file provider enabled and mounts the required directories:

```yaml docker-compose.yml theme={null}
traefik:
  image: traefik:v3.6
  command:
    [...]
    - "--providers.file.directory=/etc/traefik/dynamic"
    - "--providers.file.watch=true"
    [...]
  volumes:
    - "/var/run/docker.sock:/var/run/docker.sock:ro"
    - "./traefik/acme:/etc/traefik/acme"
    - "./traefik/dynamic:/etc/traefik/dynamic"
    - "./traefik/certs:/etc/traefik/certs"
```

So you do **not** need to modify the Traefik service. It is enough to place the certificate files, add a dynamic configuration, and disable the ACME resolver on the router.

***

## Setup

<Steps>
  <Step title="Place the certificate files">
    Put the certificate and key into the `traefik/certs` directory **next to your `docker-compose.yml`**:

    ```bash theme={null}
    mkdir -p traefik/certs traefik/dynamic
    cp /path/to/fullchain.pem traefik/certs/varios.crt
    cp /path/to/privkey.pem   traefik/certs/varios.key
    ```

    Protect the private key from unauthorized access:

    ```bash theme={null}
    chmod 644 traefik/certs/varios.crt
    chmod 600 traefik/certs/varios.key
    ```

    <Warning>
      Never store private keys in a directory served by a web server, and never commit them to version control.
    </Warning>
  </Step>

  <Step title="Create the dynamic configuration">
    Create the file `traefik/dynamic/certificates.yml`. Traefik picks it up automatically through the file provider — the paths refer to the paths **inside the container** (`/etc/traefik/certs`), not to host paths.

    ```yaml traefik/dynamic/certificates.yml theme={null}
    tls:
      certificates:
        - certFile: /etc/traefik/certs/varios.crt
          keyFile: /etc/traefik/certs/varios.key

      stores:
        default:
          defaultCertificate:
            certFile: /etc/traefik/certs/varios.crt
            keyFile: /etc/traefik/certs/varios.key
    ```

    <Note>
      The `certificates` section makes the certificate available for matching hostnames. `stores.default.defaultCertificate` additionally sets it as the default certificate — that way Traefik also answers requests without a matching hostname with your certificate instead of the generated placeholder `TRAEFIK DEFAULT CERT`.
    </Note>
  </Step>

  <Step title="Disable Let's Encrypt on the router">
    In `docker-compose.yml`, remove the certificate resolver label from the `php` service. Keep the `tls=true` label — only the automatic issuance goes away:

    ```yaml docker-compose.yml theme={null}
    php:
      [...]
      labels:
        - "traefik.enable=true"
        # removed: traefik.http.routers.${STACKNAME}.tls.certresolver=le
        - "traefik.http.routers.${STACKNAME}.tls=true"
        - "traefik.http.routers.${STACKNAME}.rule=Host(`${PROJECT_DOMAIN}`)"
        - "traefik.http.routers.${STACKNAME}.entrypoints=websecure"
        - "traefik.http.services.${STACKNAME}.loadbalancer.server.port=80"
        - "traefik.http.routers.http-${STACKNAME}.middlewares=redirect-https"
        - "traefik.http.routers.http-${STACKNAME}.rule=Host(`${PROJECT_DOMAIN}`)"
        - "traefik.http.routers.http-${STACKNAME}.entrypoints=web"
        - "traefik.http.middlewares.redirect-https.redirectscheme.scheme=https"
        - "traefik.http.middlewares.redirect-https.redirectscheme.permanent=true"
    ```

    <Warning>
      If `certresolver=le` stays in place, Traefik keeps trying to issue a Let's Encrypt certificate. In isolated networks this fails permanently and floods the log with ACME errors.
    </Warning>

    Optionally, you can also remove the ACME options from the Traefik service's `command` block (`--certificatesresolvers.le.*`) if Let's Encrypt is no longer used at all.
  </Step>

  <Step title="Apply the changes">
    ```bash theme={null}
    docker compose up -d
    ```

    The label change only takes effect once the container is recreated; `docker compose up -d` handles that automatically. Changes to files below `traefik/dynamic` and `traefik/certs`, on the other hand, are picked up **without a restart** thanks to `--providers.file.watch=true`.
  </Step>
</Steps>

***

## Verification

After startup, check which certificate Traefik serves:

```bash theme={null}
openssl s_client -connect chat.example.com:443 -servername chat.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
```

<Check>
  The output must show the issuer and validity of **your** certificate. If `TRAEFIK DEFAULT CERT` appears instead, the dynamic configuration was not loaded.
</Check>

Check the certificate chain with:

```bash theme={null}
curl -vI https://chat.example.com 2>&1 | grep -E "issuer|subject|SSL certificate"
```

Traefik logs errors while loading the dynamic configuration to the container log:

```bash theme={null}
docker compose logs traefik | tail -50
```

***

## Renewing the certificate

To replace an expiring certificate, simply swap the two files:

```bash theme={null}
cp /path/to/new-fullchain.pem traefik/certs/varios.crt
cp /path/to/new-privkey.pem   traefik/certs/varios.key
chmod 600 traefik/certs/varios.key
```

Thanks to `--providers.file.watch=true`, Traefik usually detects the change automatically. If the old certificate is still served, force a reload:

```bash theme={null}
docker compose restart traefik
```

<Tip>
  Set a reminder ahead of the expiry date. Unlike Let's Encrypt, a manually provided certificate does **not** renew itself.
</Tip>

***

## Multiple domains

If VARIOS AI should be reachable under several hostnames, add more entries under `certificates`. Traefik selects the matching certificate based on the hostname sent via SNI:

```yaml traefik/dynamic/certificates.yml theme={null}
tls:
  certificates:
    - certFile: /etc/traefik/certs/chat.crt
      keyFile: /etc/traefik/certs/chat.key
    - certFile: /etc/traefik/certs/ki.crt
      keyFile: /etc/traefik/certs/ki.key

  stores:
    default:
      defaultCertificate:
        certFile: /etc/traefik/certs/chat.crt
        keyFile: /etc/traefik/certs/chat.key
```

The additional hostnames must also appear in the router rule:

```yaml docker-compose.yml theme={null}
- "traefik.http.routers.${STACKNAME}.rule=Host(`chat.example.com`) || Host(`ki.example.com`)"
```

A **wildcard certificate** (`*.example.com`) needs no special handling — it is configured like any other certificate and applies to all matching hostnames.

***

## Hardening TLS options

The file provider also lets you define the permitted TLS versions and cipher suites. Add another file in the same directory:

```yaml traefik/dynamic/options.yml theme={null}
tls:
  options:
    default:
      minVersion: VersionTLS12
      sniStrict: true
      cipherSuites:
        - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
        - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
```

<Note>
  The `default` option automatically applies to every router that has no TLS option of its own. `sniStrict: true` rejects connections without a matching hostname — verify beforehand that all clients and monitoring systems use SNI. The cipher suite list only affects TLS 1.2; the TLS 1.3 suites are fixed.
</Note>

***

## Troubleshooting

| Symptom                                         | Cause                                              | Resolution                                                                                                     |
| ----------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Browser shows `TRAEFIK DEFAULT CERT`            | Dynamic configuration not loaded                   | Check the `.yml` file extension and the `traefik/dynamic` location, review the Traefik log                     |
| `unable to generate TLS certificate` in the log | ACME resolver still active                         | Remove the `tls.certresolver=le` label from the `php` service                                                  |
| `tls: private key does not match public key`    | Certificate and key do not belong together         | Compare the modulus checksums of both files (see [Prerequisites](#prerequisites))                              |
| Certificate warning despite a valid certificate | Intermediate certificates missing                  | Concatenate server certificate and intermediates into one file: `cat server.crt intermediate.crt > varios.crt` |
| `permission denied` when reading the key        | File permissions too restrictive for the container | Check the file owner; Traefik runs as root inside the container and must be able to read the key               |
| Change has no effect                            | Label change without recreating the container      | Run `docker compose up -d` (not just `restart`)                                                                |
