DEV Community

Cover image for AI Security Engineer Journey in 2026: Recovering, Migrating and monitoring Traefik v3, Portainer across the Kahiether Stack
Mino Randriamanivo
Mino Randriamanivo

Posted on

AI Security Engineer Journey in 2026: Recovering, Migrating and monitoring Traefik v3, Portainer across the Kahiether Stack

Hi👋 PS : this is a follow up of @portainerio and the request for a more updated version of Traefik in 2022 here : https://medium.com/@randriamanivo/devops-journey-in-2022-using-portainer-traefik-and-docker-compose-to-quickly-deploy-an-example-aa5a2c43645d

金繕い (Kintsugi) is a Japanese art where broken pottery is repaired with lacquer mixed with gold instead making the fractures become part of the object's history. Like Kintsugi, resilience is not about pretending nothing happened and putting everything like they were originally. It is about using disruption to build a system that carries the lessons of the past into a more resilient future.

It was somewhere between the Basque Land and Cantabria (shout out to Lina) that the servers fell down and an alert email came. I had been postponing the full migration for a long time, but why ? Traefik v1 to v3 alone meant rewriting the label syntax across every stack, meaning having hundreds of line to rewrite and test them.

This time the timing was not chosen, an attack triggered the migration and then here we are looking upon the main parts including analyzing the problem, defining solutions, applying the corrections, and monitoring the result. The mains section is to describe the Portainer and Traefik migration.

Finding Out How Bad It Was


Before making any change, first step is to run diagnostic pass on the server: uptime, disk, memory, running containers, listening ports.

echo "=== DATE / UPTIME ==="
date
uptime
echo "=== DISQUE ==="
df -h
df -i
echo "=== MEMOIRE ==="
free -h
echo "=== CHARGE ==="
top -b -n1 | head -25
echo "=== DOCKER SERVICE ==="
sudo systemctl status docker --no-pager -l
echo "=== CONTAINERD SERVICE ==="
sudo systemctl status containerd --no-pager -l
echo "=== PORTS EN ECOUTE ==="
sudo ss -lntup | grep -E ':(22|80|443|8000|8080|9000|9443)\b' || true
echo "=== CONTENEURS ==="
sudo docker ps -a \
  --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}'
echo "=== CONTENEURS EN ECHEC ==="
sudo docker ps -a \
  --filter status=exited \
  --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
Enter fullscreen mode Exit fullscreen mode

Result: 0 containers but untouched volumes and networks. The main vulnerabilities that needed patch was the docker sock exposition. The second was the access to infrastructure administration especially Portainer and Traefik. The Ci/Cd was partly done with Github Actions and DockerHub which were already secure.

Rebuilding the Base System

Before touching Traefik, Portainer, or security settings, the OS and Docker Engine needed a refresh : some operations were done to add a little layer of security there : Ban, IP whitelisting, bust and rate limiter. The next step is to update Docker from the official repository.

sudo apt-get update
sudo apt-get install \
  docker-ce \
  docker-ce-cli \
  containerd.io \
  docker-buildx-plugin \
  docker-compose-plugin
Enter fullscreen mode Exit fullscreen mode

Preparing the Traefik V3

Security work is not a one-time task. What follows reduces exposure, it does not remove risk permanently. Set correct permissions on the ACME storage file used by Let's Encrypt, since a world-readable acme.json is a documented weak point in Traefik setups.

sudo mkdir -p /opt/traefik-v3
sudo touch /opt/traefik-v3/acme.json
sudo chmod 600 /opt/traefik-v3/acme.json
sudo chown admin:admin /opt/traefik-v3/acme.json
Enter fullscreen mode Exit fullscreen mode

Then the upcoming step is to review exposed ports and dashboards against what actually needed to be public. This part, along with monitoring and resource tuning, took most of the time. Upgrading Traefik and Portainer themselves took about one hour combined.

Traefik Upgrade

Traefik v1 to v3 required a rewrite of the label syntax for every stack, since the static configuration, dynamic configuration, and middleware format changed between major versions.

Router and entrypoint configuration for Traefik v3.7.6, with sensitive values replaced by placeholders:

  docker-socket-proxy:
    image: mino189/ghcr.io--tecnativa--docker-socket-proxy:v0.4.2
    container_name: docker-socket-proxy
    restart: unless-stopped
    environment:
      # Deny all Docker API write operations
      POST: "0"
      # Docker API sections required by Traefik discovery
      PING: "1"
      VERSION: "1"
      EVENTS: "1"
      CONTAINERS: "1"
      NETWORKS: "1"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
    networks:
      - docker-api
proxy:
    image: mino189/traefik:v3.7.6
    container_name: traefik-v3
    restart: unless-stopped
    command:
      # Logging
      - "--log.level=INFO"
      - "--accesslog=true"
      # Dashboard
      - "--api.dashboard=true"
      - "--api.insecure=false"
      # Docker provider
      - "--providers.docker=true"
      - "--providers.docker.endpoint=tcp://docker-socket-proxy:2375"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=web"
      - "--providers.docker.watch=true"
      # HTTP entrypoint
      - "--entrypoints.web.address=:80"
      # HTTPS entrypoint
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.websecure.http3=true"
      # Redirect HTTP to HTTPS
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"
      # Let's Encrypt
      - "--certificatesresolvers.letsencrypt.acme.email=<TO_CHANGE_EMAIL>"
      - "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - "/opt/traefik-v3/acme.json:/acme.json"
    networks:
      - web
      - docker-api
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=web"
      - "traefik.http.routers.traefik-dashboard.rule=Host(\`<TO_CHANGE_DOMAIN>\`)"
      - "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
      - "traefik.http.routers.traefik-dashboard.tls=true"
      - "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik-dashboard.service=api@internal"
      - "traefik.http.routers.traefik-dashboard.middlewares=dashboard-ipallowlist,dashboard-ratelimit,dashboard-security"
      - "traefik.http.middlewares.dashboard-ipallowlist.ipallowlist.sourcerange=xxxxxxxx/xx"
      - "traefik.http.middlewares.dashboard-ratelimit.ratelimit.average=10"
      - "traefik.http.middlewares.dashboard-ratelimit.ratelimit.burst=20"
      - "traefik.http.middlewares.dashboard-ratelimit.ratelimit.period=1s"
      - "traefik.http.middlewares.dashboard-security.headers.contenttypenosniff=true"
      - "traefik.http.middlewares.dashboard-security.headers.framedeny=true"
      - "traefik.http.middlewares.dashboard-security.headers.referrerpolicy=no-referrer"
      - "traefik.http.middlewares.dashboard-security.headers.stsseconds=31536000"
      - "traefik.http.middlewares.dashboard-security.headers.stsincludesubdomains=true"
Enter fullscreen mode Exit fullscreen mode

Two changes matter compared to v1. HTTP/3 moved out of experimental status and became fully production ready in Traefik v3. And starting with v3.2, Traefik added a Fast Proxy engine, built on a zero-allocation pipeline, delivering a 50% performance improvement over the standard engine. the stack run v3.7.6, past that release, so this applies here. Official write-up: https://traefik.io/blog/traefik-proxy-v3-2-a-munster-release

Portainer Upgrade

Portainer was upgraded in the same row as Traefik, there is always a compatibility to check between Docker, Traefik and Portainer. The Portainer version is " Portainer Community Edition 2.39.4 LTS".

Label Rewrite

Every Traefik label across all 70 stacks needed to be rewritten for v3 syntax. The task itself is a configuration migration: the frontend rule, rate limit block, and custom headers on each service all needed translating into the new router, middleware, and service structure.

Here is one actual example, before and after, for a single service, with the CSP domain list anonymised:

Before (Traefik v1 syntax):

labels:
  - "traefik.enable=true"
  - "traefik.frontend.rule=Host:tools.hongkoala.com"
  - "traefik.docker.network=web"
  - "traefik.http.services.hongkoala-tools-node.server.port=3000"
  - "traefik.frontend.rateLimit.rateSet.rate=10"
  - "traefik.frontend.rateLimit.rateSet.burst=5"
  - "traefik.frontend.headers.customResponseHeaders=Content-Security-Policy:frame-ancestors https://<domain-1>.tld https://*.<domain-1>.tld https://<domain-2>.tld https://*.<domain-2>.tld https://<domain-3>.tld https://*.<domain-3>.tld"
Enter fullscreen mode Exit fullscreen mode

After (Traefik v3 syntax):

labels:
  - "traefik.enable=true"
  - "traefik.docker.network=web"
  - "traefik.http.routers.hongkoala-tools.rule=Host(\`tools.hongkoala.com\`)"
  - "traefik.http.routers.hongkoala-tools.entrypoints=websecure"
  - "traefik.http.routers.hongkoala-tools.tls=true"
  - "traefik.http.routers.hongkoala-tools.tls.certresolver=letsencrypt"
  - "traefik.http.routers.hongkoala-tools.service=hongkoala-tools"
  - "traefik.http.routers.hongkoala-tools.middlewares=hongkoala-tools-ratelimit,hongkoala-tools-headers"
  - "traefik.http.services.hongkoala-tools.loadbalancer.server.port=3000"
  - "traefik.http.middlewares.hongkoala-tools-ratelimit.ratelimit.average=100"
  - "traefik.http.middlewares.hongkoala-tools-ratelimit.ratelimit.period=1s"
  - "traefik.http.middlewares.hongkoala-tools-ratelimit.ratelimit.burst=50"
  - "traefik.http.middlewares.hongkoala-tools-headers.headers.customResponseHeaders.Content-Security-Policy=frame-ancestors https://<domain-1>.tld https://*.<domain-1>.tld https://<domain-2>.tld https://*.<domain-2>.tld https://<domain-3>.tld https://*.<domain-3>.tld"
Enter fullscreen mode Exit fullscreen mode

Three structural changes are visible here: the frontend rule becomes a router rule using the Host()\ function syntax, rate limiting moves from a frontend.rateLimit\ block into a named, reusable middleware, and the CSP header moves from customResponseHeaders\ as a flat string into a middleware-scoped customResponseHeaders.Content-Security-Policy\ key.

Doing this by hand across 70 stacks would have meant opening each docker-compose file individually, checking every label against the v3 documentation, and testing one at a time, since a malformed Traefik label usually fails silently instead of throwing an error. This is where AI accelerated the work rather than doing it: I fed each v1 block into a model along with the target v3 syntax as context, and it produced the translation shown above directly. That turned the rewrite into a review task across all 70 stacks instead of a manual, file-by-file rewrite, which is the direct reason the Traefik and Portainer upgrades took about one hour combined.

ATTENTION : I have some websites with basic auth, It is strictly recommended to not feed AI LLM with code containing credential, and if it's already send, no panic, then we just need to change the credentials.

Monitoring Setup

For a simple server like this a simple service monitoring is enough : A Google Apps Script checks every public site on a schedule, logs response time and status to a spreadsheet, and sends an alert email if a site is down or slow. IDs and email addresses below are replaced with placeholders.

function monitorWebsites() {
  // --- CONFIGURATION START (Values Kept) ---

  // 1. List of websites to monitor, separated by commas.
  const SITES_TO_MONITOR_STRING = "https://kahiether.com/,https://hery.kahiether.com/,https://nali.kahiether.com/,https://jiao.kahiether.com/,https://kizuna.kahiether.com/,https://shiro.kahiether.com/,https://mypa.kahiether.com/,https://resizer.kahiether.com/,https://mandika.kahiether.com/,https://mamaki.kahiether.com/,https://mpaka.kahiether.com/";

  // 2. ID of the target Google Spreadsheet.
  const SPREADSHEET_ID = '<TO_CHANGE_SPREADSHEET_ID>';

  // 3. Name of the sheet (tab) within the spreadsheet where data will be stored.
  const SHEET_NAME = 'xxxxxxxxxxxx';

  // 4. Comma-separated list of email addresses for receiving alerts.
  const ALERT_EMAILS = "<TO_CHANGE_EMAIL_1>,<TO_CHANGE_EMAIL_2>";

  // 5. Alert threshold for response time in milliseconds.
  const ALERT_THRESHOLD_MS = 1000; // Example: 1 seconds

  // --- CONFIGURATION END ---
  ...........
  // --- Section Sites DOWN/Erreur ---
  if (downAlerts.length > 0) {
    body += \`*** Les sites suivants sont HORS SERVICE ou ont signalé un code d'erreur (non-200) : ***\n\`;
    downAlerts.forEach(alert => {
      body += \`- \${alert.siteUrl} | Erreur: \${alert.detail} (Statut: \${alert.value})\n\`;
    });
    body += '\n';
  }

  // --- Section Problèmes de Performance ---
  if (perfAlerts.length > 0) {
    body += \`*** Les sites suivants ont des problèmes de PERFORMANCE (Latence > \${threshold} ms) : ***\n\`;
    body += \`(Le seuil d'alerte est fixé à \${threshold} ms)\n\`;
    perfAlerts.forEach(alert => {
      body += \`- \${alert.siteUrl} | Temps de réponse: \${alert.value} ms. (Vérification: \${alert.timestamp.toLocaleTimeString()})\n\`;
    });
    body += '\n';
  }
Enter fullscreen mode Exit fullscreen mode

This script checks every site in SITES_TO_MONITOR_STRING\, logs each result to the Monitoring_Log\ sheet, and triggers two categories of alert: sites returning a non-200 status, and sites responding slower than ALERT_THRESHOLD_MS\ (1000 ms in this config).

Image Storage

Once the main part of the migration was done, another problem appeared. Some docker hub images were moved between 2021-2022 and today into legacy by their provider, so it was necessary to find the location of the image and make a copy. Two separate things happen here(not be confused).

Public or third-party base images (nginx, postgres, node, etc.) are mirrored into my Docker Hub account through a GitHub Actions workflow, rather than pulled directly from the public namespace at deploy time.

name: Mirror Docker Images

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  mirror:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          registry: docker.io
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Mirror images
        shell: bash
        env:
          DOCKERHUB_USERNAME: ${{ secrets.DOCKER_USERNAME }}
        run: |
          set -Eeuo pipefail

          images_file="${GITHUB_WORKSPACE}/images.txt"

          if [[ ! -f "${images_file}" ]]; then
            echo "::error::images.txt not found"
            exit 1
          fi

          if [[ -z "${DOCKERHUB_USERNAME}" ]]; then
            echo "::error::DOCKER_USERNAME is empty"
            exit 1
          fi

          docker buildx version

          mirrored=0
          failed=0

          #
          # Retry a command when a registry returns HTTP 429.
          #
          retry_on_rate_limit() {
            local max_attempts=5
            local attempt=1
            local delay=15
            local output_file
            local status

            output_file="$(mktemp)"

            while true; do
              if "$@" > >(tee "${output_file}") 2> >(tee -a "${output_file}" >&2); then
                rm -f "${output_file}"
                return 0
              else
                status=$?
              fi

              if ! grep -qiE '429|Too Many Requests' "${output_file}"; then
                rm -f "${output_file}"
                return "${status}"
              fi

              if (( attempt >= max_attempts )); then
                echo "::error::Command failed after ${max_attempts} attempts: $*" >&2
                rm -f "${output_file}"
                return "${status}"
              fi

              echo "::warning::Registry rate limit encountered. Retrying in ${delay} seconds..." >&2

              sleep "${delay}"

              attempt=$((attempt + 1))
              delay=$((delay * 2))

              : > "${output_file}"
            done
          }

          #
          # Copy a multi-platform image to Docker Hub.
          #
          copy_image() {
            local source="$1"
            local destination="$2"

            docker buildx imagetools create \
              --tag "${destination}" \
              "${source}"
          }

          while IFS= read -r image || [[ -n "${image}" ]]; do
            image="${image%$'\r'}"

            # Trim leading and trailing whitespace.
            image="${image#"${image%%[![:space:]]*}"}"
            image="${image%"${image##*[![:space:]]}"}"

            # Skip blank lines and comments.
            [[ -z "${image}" || "${image}" == \#* ]] && continue

            first_component="${image%%/*}"

            #
            # Resolve the source registry.
            #
            if [[ "${image}" != */* ]]; then
              registry="docker.io"
              source="docker.io/library/${image}"
              repository_reference="library/${image}"

            elif [[ "${first_component}" == *.* ||
                    "${first_component}" == *:* ||
                    "${first_component}" == "localhost" ]]; then
              registry="${first_component}"
              source="${image}"
              repository_reference="${image#*/}"

            else
              registry="docker.io"
              source="docker.io/${image}"
              repository_reference="${image}"
            fi

            #
            # Extract the repository and destination tag.
            #
            tag="latest"

            if [[ "${repository_reference}" == *@* ]]; then
              repository="${repository_reference%%@*}"
              digest="${repository_reference#*@}"
              digest_value="${digest#sha256:}"
              tag="digest-${digest_value:0:12}"

            elif [[ "${repository_reference##*/}" == *:* ]]; then
              repository="${repository_reference%:*}"
              tag="${repository_reference##*:}"

            else
              repository="${repository_reference}"
            fi

            #
            # Encode the registry and full repository path into one valid
            # Docker Hub repository name.
            #
            destination_repository="${registry}--${repository}"
            destination_repository="${destination_repository//\//--}"
            destination_repository="${destination_repository,,}"

            destination="docker.io/${DOCKERHUB_USERNAME}/${destination_repository}:${tag}"

            echo
            echo "Source      : ${source}"
            echo "Destination : ${destination}"
            echo "Mirroring image..."

            #
            # Create or update the destination manifest.
            # Existing blobs are reused by the registry.
            #
            if retry_on_rate_limit \
              copy_image "${source}" "${destination}"; then
              echo "Mirrored: ${destination}"
              mirrored=$((mirrored + 1))
            else
              echo "::error::Failed to mirror ${source}"
              failed=$((failed + 1))
            fi

            #
            # Reduce request bursts when processing consecutive images.
            #
            sleep 10

          done < "${images_file}"

          echo
          echo "Mirrored: ${mirrored}"
          echo "Failed:   ${failed}"

          if (( failed > 0 )); then
            exit 1
          fi
Enter fullscreen mode Exit fullscreen mode

The workflow is manually triggered, reads image names from images.txt\, and uses docker buildx imagetools create\ to copy each manifest from the public namespace into the Docker Hub account under the same name, confirming each copy with docker buildx imagetools inspect\.

The self made Kahiether applications are different: each one has its own Dockerfile and gets built and pushed from its own repository, not mirrored.

These apps follow the same design constraints across the board, described here: https://kahiether.com/serving-a-clear-single-purpose-kahiether-microservice/

Concretely, each Kahiether service is kept intentionally small. A service is typically built from three main files, main.js, styles.js, and index.html, with no folders, and each file kept under around 1,000 lines including spacing. On dependencies, the backend relies as possible on only two dependencies, Express and Node.js, while the front end uses zero dependencies and no bundler like Webpack or Vite. Resource footprint follows the same logic: each microservice is expected to run under 0.2 vCPU and under 125 MB of RAM. Every service is also offline-first by default, shipping with a service worker so files are stored locally and the service keeps working without an internet connection once installed.

This was the good new, while all the applications : PosgreSQL, mariaDB, Ghost, Wordpress, Wikijs, portainer, traefik, netstat, neko, MongoDB, n8n, node-red, openWebUi, Kizuna, ChangTan LLM, etc... fell down, all the Kahiether services (https://hery.kahiether.com/, https://nali.kahiether.com/, ..) were still functional on all devices. It was the perfect test.

Isolation During the Outage, and an Earlier Lesson

Not only the Kahiether app survived but all the data are stored client side, so they can come and burn the server anytime, all user can still use their app (This is a strategy that Firebase already applies for years ago, websites like Netflix and other also have very interesting offline capabilities like watching local movies). A direct result of an offline-first architecture decision made years earlier.

Apps built under this approach are designed to work without depending on the server being reachable at that moment. During the outage, hery.kahiether.com\ stayed usable specifically because it follows this pattern: get the app once, keep it working offline, update only when a full update can complete safely.

NOTE : That strategy is documented here, with the reasoning behind it going back to unreliable network conditions in Madagascar: https://kahiether.com/get-once-always-work-update-network/

This was not the first time a security incident had shaped how the apps are built. Before this, a WordPress instance where we ran Digigasy on a shared hosting was compromised through a vulnerable plugin in an another WordPress in the same hosting, This brough the first step of the architecture Docker.

Different attack, same underlying pattern: shared environments and outdated dependencies concentrate risk in places we do not always control.

The offline-first, isolated approach behind Kahiether was built with that kind of risk in mind from the start, you can attack and destroy the server, the data and the application is not there it's client side.There are still a lot of improvements that can be done on this server, lets just remember that its not a data critical server, we don't store visitor data, only and mainly content.

But the question that always came along was, AI was used to check the yaml, then to rewrite very quicky some labels with only replacing the domain name app by app. But that AI was online, so if it was down or inaccessible my the redeployment would have taken much more time. AI reduced the label rewrite across 70 stacks from a manual, documentation-checking task to a review task, as shown in the before/after example above.

Wrap up : Where AI LLM is Taking Us ?

The bigger question is what this means for long term future. In my opinion, the real bottleneck AI LLM Models faces today is not generation speed, or accuracy. It is :
1/authentic data, since a model cannot reliably improve by training on its own generated output. We will always need people to provide this data.
2/Offline capabilities : In remote area with no internet access and only local network with the present devices.

Machines, Factories, Robots automated physical work. But the current LLM AI is automating some cognitive work, especially those who can be repeated in the same condition easily (like replace the domain ".rule=Host(hongkoala.com\)" by another and rewrite the full label.)
Whether this changes our market dynamic depends on adaptation, the way it did when cars replaced horses for transport, and when electricity replaced oil lamps.

The tools change, the need for people who understand what the tools are doing, and who can supply the real context a model does not have on its own, does not disappear.

AI did not run this migration. It removed the repetitive part of it, so the work went to understanding security risks, monitoring, and resource tuning instead of rewriting hundreds (or I think thousands) of labels from traefik v1 to v3.
We might see soon the discussion about AI LLM powered autonomous devices

That's all.

Happy to share the struggles and these experiments. A long journey about learning, building, innovating in the most challenging conditions 🚀

Read more: https://medium.com/@randriamanivo/intrapreneur-innovator-journey-2026-building-innovative-products-and-accelerating-it-with-1de3a9d52bc1

Top comments (0)