⚙️ Infrastructure Advanced ⏱️ 20 min

Deploying a Telegram Bot to Kubernetes with Flux

Complete guide to deploying the BlueRobin Telegram Bot on Kubernetes, covering Docker builds, Kustomize overlays, secrets management with Infisical, and GitOps with Flux CD.

By Victor Robin

Introduction

After building the Telegram Bot service and notification pipeline, we need to deploy it to our Kubernetes cluster. This article covers the complete deployment stack:

  • Docker: Multi-stage build with chiseled base images
  • Kustomize: Base + overlay pattern for staging/production
  • Infisical: Secrets management via ExternalSecrets
  • Flux CD: GitOps-driven deployment

Deployment Architecture

flowchart TB
    subgraph GitHub["GitHub"]
        App[bluerobin-app]
        Infra[bluerobin-infra]
    end
    
    subgraph CI["GitHub Actions"]
        Build[Build & Push Image]
        Scan[Security Scan]
    end
    
    subgraph Registry["Private Registry"]
        Image["archives-telegram-bot:sha-xxx"]
    end
    
    subgraph Secrets["Infisical"]
        BotToken[TELEGRAM_BOT_TOKEN]
        DBCreds[Database Credentials]
        MinioCreds[MinIO Credentials]
    end
    
    subgraph Cluster["K3s Cluster"]
        subgraph Flux["Flux CD"]
            Source[GitRepository]
            Kustomize[Kustomization]
        end
        
        subgraph NS["archives-staging namespace"]
            ExtSecret[ExternalSecret]
            Secret[Secret]
            Deploy[Deployment]
            CM[ConfigMap]
            Svc[Service]
        end
        
        subgraph Data["data-layer namespace"]
            NATS[NATS]
            PG[PostgreSQL]
            MinIO[MinIO]
        end
    end
    
    App -->|1. Push| CI
    CI -->|2. Build| Image
    Infra -->|3. Sync| Source
    Source --> Kustomize
    Kustomize -->|4. Apply| NS
    Secrets -->|5. Sync| ExtSecret
    ExtSecret --> Secret
    Secret --> Deploy
    CM --> Deploy
    Deploy --> NATS
    Deploy --> PG
    Deploy --> MinIO
    
    classDef github fill:#24292e,color:#fff
    classDef ci fill:#2088ff,color:#fff
    classDef registry fill:#0db7ed,color:#fff
    classDef secrets fill:#7c3aed,color:#fff
    classDef flux fill:#5468ff,color:#fff
    classDef k8s fill:#326ce5,color:#fff
    
    class App,Infra github
    class Build,Scan ci
    class Image registry
    class BotToken,DBCreds,MinioCreds secrets
    class Source,Kustomize flux
    class ExtSecret,Secret,Deploy,CM,Svc,NATS,PG,MinIO k8s

The Dockerfile

We use a multi-stage build with Microsoft’s chiseled base images for security:

📄
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown

WORKDIR /src

# Copy solution and project files for layer caching
COPY ["BlueRobin.sln", "./"]
COPY ["Directory.Build.props", "./"]
COPY ["src/SharedKernel/SharedKernel.csproj", "src/SharedKernel/"]
COPY ["src/Archives.Core/Archives.Core.csproj", "src/Archives.Core/"]
COPY ["src/Archives.Application/Archives.Application.csproj", "src/Archives.Application/"]
COPY ["src/Archives.Infrastructure/Archives.Infrastructure.csproj", "src/Archives.Infrastructure/"]
COPY ["src/Archives.TelegramBot/Archives.TelegramBot.csproj", "src/Archives.TelegramBot/"]

# Restore dependencies
RUN dotnet restore "src/Archives.TelegramBot/Archives.TelegramBot.csproj"

# Copy source code
COPY ["src/", "src/"]

# Build and publish
WORKDIR "/src/src/Archives.TelegramBot"
RUN dotnet publish "Archives.TelegramBot.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore \
    /p:UseAppHost=false

# =============================================================================
# Stage 2: Runtime
# =============================================================================
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra AS final

# Build metadata labels
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
LABEL org.opencontainers.image.revision=$BUILD_COMMIT
LABEL org.opencontainers.image.created=$BUILD_TIME
LABEL org.opencontainers.image.title="BlueRobin Telegram Bot"
LABEL org.opencontainers.image.description="Telegram bot for BlueRobin document management"

# Required for ICU/globalization support
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

WORKDIR /app
COPY --from=build /app/publish .

# Run as non-root user (chiseled images use uid 1654)
USER $APP_UID

EXPOSE 8080

ENTRYPOINT ["dotnet", "BlueRobin.Archives.TelegramBot.dll"]

Kustomize Structure

We use a base + overlays pattern:

apps/archives-telegram-bot/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml
│   ├── service.yaml
│   └── configmap.yaml
├── overlays/
│   ├── staging/
│   │   └── kustomization.yaml
│   └── prod/
│       └── kustomization.yaml
├── externalsecret.yaml
└── kustomization.yaml

Base Resources

📄
apiVersion: apps/v1
kind: Deployment
metadata:
  name: archives-telegram-bot
  labels:
    app: archives-telegram-bot
    component: telegram
spec:
  replicas: 1
  selector:
    matchLabels:
      app: archives-telegram-bot
  template:
    metadata:
      labels:
        app: archives-telegram-bot
        component: telegram
      annotations:
        linkerd.io/inject: enabled
        config.linkerd.io/skip-outbound-ports: "4222"  # NATS uses its own mTLS
        signoz.io/scrape: "true"
        signoz.io/port: "8080"
        signoz.io/path: "/metrics"
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      imagePullSecrets:
        - name: registry-pull-secret
      containers:
        - name: telegram-bot
          image: 192.168.0.5:5005/bluerobin/archives-telegram-bot:latest
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: false
            runAsNonRoot: true
            capabilities:
              drop:
                - ALL
          ports:
            - containerPort: 8080
              name: http
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 30
            timeoutSeconds: 5
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 3
          resources:
            requests:
              memory: "128Mi"
              cpu: "50m"
            limits:
              memory: "256Mi"
              cpu: "200m"
          envFrom:
            - configMapRef:
                name: archives-telegram-bot-config
            - secretRef:
                name: archives-telegram-bot-secret
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: "Production"
            - name: ConnectionStrings__DefaultConnection
              value: "Host=$(ARCHIVES_DB_HOST);Port=$(ARCHIVES_DB_PORT);Database=$(ARCHIVES_DB_NAME);Username=$(ARCHIVES_DB_USER);Password=$(ARCHIVES_DB_PASSWORD)"
            - name: Telegram__BotToken
              valueFrom:
                secretKeyRef:
                  name: archives-telegram-bot-secret
                  key: TELEGRAM_BOT_TOKEN
            - name: OTEL_EXPORTER_OTLP_ENDPOINT
              value: "http://signoz-otel-collector.platform.svc.cluster.local:4317"
            - name: OTEL_SERVICE_NAME
              value: "archives-telegram-bot"

ConfigMap

📄
apiVersion: v1
kind: ConfigMap
metadata:
  name: archives-telegram-bot-config
data:
  # NATS connection (shared data-layer)
  NATS__Url: "nats://nats.data-layer.svc.cluster.local:4222"
  
  # MinIO storage (shared data-layer)
  Minio__Endpoint: "minio.data-layer.svc.cluster.local:9000"
  Minio__UseSSL: "false"
  
  # Database host (resolved via secrets)
  ARCHIVES_DB_HOST: "postgres-rw.data-layer.svc.cluster.local"
  ARCHIVES_DB_PORT: "5432"
  
  # Qdrant vector store
  Qdrant__Endpoint: "http://qdrant.data-layer.svc.cluster.local:6334"
  Qdrant__CollectionName: "staging-documents"
  
  # RAG configuration
  Rag__OllamaBaseUrl: "http://ollama.ai.svc.cluster.local:11434"
  Rag__ChatModelId: "llama3.3:8b"
  Rag__MaxChunks: "5"
  
  # Telegram bot settings (non-sensitive)
  Telegram__BotUsername: "BlueRobinBot"
  Telegram__MaxUploadSizeBytes: "52428800"  # 50MB
  
  # Environment prefix for NATS subjects
  Environment__Prefix: "staging"

Staging Overlay

📄
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: archives-staging

resources:
  - ../../base

patches:
  - target:
      kind: ConfigMap
      name: archives-telegram-bot-config
    patch: |-
      - op: replace
        path: /data/Environment__Prefix
        value: "staging"
      - op: replace
        path: /data/ARCHIVES_DB_NAME
        value: "archives_staging"
      - op: replace
        path: /data/Qdrant__CollectionName
        value: "staging-documents"
      - op: add
        path: /data/Telegram__MiniAppUrl
        value: "https://web-staging.bluerobin.local/telegram/setup-passkey"

Production Overlay

📄
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: archives-prod

resources:
  - ../../base

patches:
  - target:
      kind: ConfigMap
      name: archives-telegram-bot-config
    patch: |-
      - op: replace
        path: /data/Environment__Prefix
        value: ""
      - op: replace
        path: /data/ARCHIVES_DB_NAME
        value: "archives_prod"
      - op: replace
        path: /data/Qdrant__CollectionName
        value: "prod-documents"
      - op: add
        path: /data/Telegram__MiniAppUrl
        value: "https://web.bluerobin.local/telegram/setup-passkey"

  - target:
      kind: Deployment
      name: archives-telegram-bot
    patch: |-
      - op: replace
        path: /spec/template/spec/containers/0/resources/requests/memory
        value: "256Mi"
      - op: replace
        path: /spec/template/spec/containers/0/resources/limits/memory
        value: "512Mi"

Secrets Management with Infisical

We use ExternalSecrets to sync secrets from Infisical:

📄
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: archives-telegram-bot-secret
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: infisical-secret-store
  target:
    name: archives-telegram-bot-secret
    creationPolicy: Owner
  data:
    # Telegram Bot Token
    - secretKey: TELEGRAM_BOT_TOKEN
      remoteRef:
        key: TELEGRAM_BOT_TOKEN
        
    # Database credentials
    - secretKey: ARCHIVES_DB_USER
      remoteRef:
        key: ARCHIVES_DB_USER
    - secretKey: ARCHIVES_DB_PASSWORD
      remoteRef:
        key: ARCHIVES_DB_PASSWORD
        
    # MinIO credentials
    - secretKey: MINIO_ACCESS_KEY
      remoteRef:
        key: MINIO_ACCESS_KEY
    - secretKey: MINIO_SECRET_KEY
      remoteRef:
        key: MINIO_SECRET_KEY

Flux Kustomization

The Flux Kustomization resource tells Flux how to deploy the app:

📄
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: archives-telegram-bot-staging
  namespace: flux-system
spec:
  interval: 5m
  path: ./apps/archives-telegram-bot/overlays/staging
  prune: true
  sourceRef:
    kind: GitRepository
    name: bluerobin-infra
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: archives-telegram-bot
      namespace: archives-staging
  timeout: 3m
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: archives-telegram-bot-prod
  namespace: flux-system
spec:
  interval: 5m
  path: ./apps/archives-telegram-bot/overlays/prod
  prune: true
  sourceRef:
    kind: GitRepository
    name: bluerobin-infra
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: archives-telegram-bot
      namespace: archives-prod
  dependsOn:
    - name: archives-telegram-bot-staging  # Deploy staging first
  timeout: 3m

Image Automation with Flux

To automatically deploy new images, we configure Flux’s image automation:

📄
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: archives-telegram-bot
  namespace: flux-system
spec:
  image: 192.168.0.5:5005/bluerobin/archives-telegram-bot
  interval: 5m
  insecure: true  # Private registry without TLS
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: archives-telegram-bot
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: archives-telegram-bot
  filterTags:
    pattern: '^sha-[a-f0-9]{7}$'  # Match sha-abc1234 tags
  policy:
    alphabetical:
      order: desc
---
apiVersion: image.toolkit.fluxcd.io/v1beta1
kind: ImageUpdateAutomation
metadata:
  name: archives-telegram-bot
  namespace: flux-system
spec:
  interval: 5m
  sourceRef:
    kind: GitRepository
    name: bluerobin-infra
  git:
    checkout:
      ref:
        branch: main
    commit:
      author:
        email: flux@bluerobin.local
        name: Flux Bot
      messageTemplate: 'chore: update archives-telegram-bot to {{.NewTag}}'
    push:
      branch: main
  update:
    path: ./apps/archives-telegram-bot
    strategy: Setters

In the deployment, add the image marker:

image: 192.168.0.5:5005/bluerobin/archives-telegram-bot:latest # {"$imagepolicy": "flux-system:archives-telegram-bot"}

GitHub Actions CI

📄
name: Build Telegram Bot

on:
  push:
    branches: [main]
    paths:
      - 'src/Archives.TelegramBot/**'
      - 'src/SharedKernel/**'
      - 'src/Archives.Core/**'
      - 'src/Archives.Application/**'
      - 'src/Archives.Infrastructure/**'
  workflow_dispatch:

jobs:
  build:
    runs-on: self-hosted  # Uses ARC runner on cluster
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        
      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: 192.168.0.5:5005
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
          
      - name: Build and Push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./src/Archives.TelegramBot/Dockerfile
          push: true
          tags: |
            192.168.0.5:5005/bluerobin/archives-telegram-bot:latest
            192.168.0.5:5005/bluerobin/archives-telegram-bot:sha-${{ github.sha }}
          build-args: |
            BUILD_COMMIT=${{ github.sha }}
            BUILD_TIME=${{ github.event.head_commit.timestamp }}
          cache-from: type=registry,ref=192.168.0.5:5005/bluerobin/archives-telegram-bot:buildcache
          cache-to: type=registry,ref=192.168.0.5:5005/bluerobin/archives-telegram-bot:buildcache,mode=max

Verification Steps

After deployment, verify everything is working:

1. Check Deployment Status

kubectl get deployments -n archives-staging -l app=archives-telegram-bot
kubectl describe deployment archives-telegram-bot -n archives-staging

2. Check Logs

kubectl logs -n archives-staging deployment/archives-telegram-bot -f

Expected output:

info: BlueRobin.Archives.TelegramBot[0]
      Telegram bot started: @BlueRobinBot (ID: 123456789)
info: BlueRobin.Archives.TelegramBot[0]
      Telegram notification worker starting, subscribing to staging.notifications.telegram

3. Test Bot Connection

Send /start to your bot in Telegram. You should get the welcome message.

4. Test Notification Flow

Upload a document via the API or web interface, then check if you receive a Telegram notification when processing completes.

Troubleshooting

IssueCauseSolution
Bot doesn’t respondToken incorrectCheck TELEGRAM_BOT_TOKEN in Infisical
No notificationsWrong NATS subjectVerify Environment__Prefix matches API
Crash loopMissing DB connectionCheck ExternalSecret sync status
Image pull errorRegistry authVerify registry-pull-secret exists

Conclusion

We’ve deployed a production-ready Telegram bot with:

  • Secure images using chiseled containers
  • Environment isolation via Kustomize overlays
  • GitOps deployment with Flux CD
  • Secrets management through Infisical + ExternalSecrets
  • Automatic updates via image automation

The entire deployment is infrastructure-as-code, version controlled, and follows our zero-touch deployment philosophy.

Complete Telegram Integration Series:

  1. Building a Telegram Bot for System Notifications
  2. Building a Telegram Mini App with Blazor Server
  3. NATS-Powered Telegram Notification System
  4. Deploying a Telegram Bot to Kubernetes with Flux (this article)