Skip to content
Back to blog Building a Custom GitHub Action for Traefik Traffic Weighting

Building a Custom GitHub Action for Traefik Traffic Weighting

CICDAWS

Building a Custom GitHub Action for Traefik Traffic Weighting

At a previous company, we needed a way to control traffic routing during deployments – shift 10% to green, validate, then gradually increase. We were using Traefik as our ingress router and had a configuration generator API that managed routing rules.

The missing piece was CI/CD integration. Engineers wanted to adjust traffic weights from their deployment pipelines without manually curling APIs or editing config files. So I built a custom GitHub Action that lets you do this:

- name: Set Traffic Weights
  uses: ./set-service-weights
  with:
    name: my-service
    settings: |-
      - taskset: blue
        weight: 90
      - taskset: green
        weight: 10

One step in your workflow, and traffic shifts. This post covers the full implementation – the Action, the API integration, SigV4 authentication, and the gotchas I hit along the way.

The Architecture

The system has three components:

┌─────────────────┐      ┌──────────────────────┐      ┌─────────────┐
│  GitHub Action  │─────►│  Traefik Generator   │─────►│   Traefik   │
│  (CI/CD step)   │ API  │  (config management) │      │  (router)   │
└─────────────────┘      └──────────────────────┘      └─────────────┘
        │                          │
        │ SigV4 Auth               │ Writes config
        ▼                          ▼
   AWS STS presigned         File provider / KV store

GitHub Action: Custom JavaScript action that takes service name and weight configuration as inputs, generates Traefik-compatible YAML, and pushes it to the generator API.

Traefik Generator API: A service that accepts routing configurations and persists them. It handles validation, namespacing, and pushing updates to Traefik’s configuration backend.

Traefik: Reads from a file provider (or KV store) and applies routing rules. When config changes, it hot-reloads without restart.

Traefik Weighted Services

Before diving into the Action, here’s what we’re generating. Traefik supports weighted load balancing natively:

http:
  services:
    my-service_service:
      weighted:
        services:
          - name: my-service-blue
            weight: 90
          - name: my-service-green
            weight: 10

    my-service-blue:
      loadBalancer:
        servers:
          - url: http://my-service-blue.internal:8080

    my-service-green:
      loadBalancer:
        servers:
          - url: http://my-service-green.internal:8080

Traffic to my-service_service gets distributed: 90% to blue, 10% to green. Adjust weights, Traefik reloads, traffic shifts. No deployment, no restarts.

The Generator API

The generator API is a simple REST service that manages Traefik configurations per namespace (service name). The key endpoints:

# Get current config for a service
GET /config/{namespace}

# Create or update config
POST /config/{namespace}
Content-Type: text/plain
Body: <traefik yaml config>

# Delete config
DELETE /config/{namespace}

Example – creating a weighted service:

curl -X POST \
  -H 'Content-Type: text/plain' \
  -H "Authorization: $(node sigv4.js)" \
  --data-binary @config.yml \
  'https://traefik-generator.moabukar.co.uk/config/my-service_service'

The API validates the YAML, stores it, and triggers Traefik to reload. The namespace prevents collisions – each service owns its configuration.

SigV4 Authentication

The generator API sits behind AWS IAM authentication. Requests must include a SigV4-signed presigned URL as the Authorization header. This is the same pattern AWS uses for cross-service authentication.

Here’s the signing script:

// sigv4.js
const { SignatureV4 } = require('@aws-sdk/signature-v4');
const { Sha256 } = require('@aws-crypto/sha256-js');
const { HttpRequest } = require('@aws-sdk/protocol-http');
const { formatUrl } = require('@aws-sdk/util-format-url');
const { fromNodeProviderChain } = require('@aws-sdk/credential-providers');

const generateSTSPresignedURL = async (region) => {
  const signer = new SignatureV4({
    credentials: fromNodeProviderChain(),
    region,
    service: 'sts',
    sha256: Sha256,
  });

  const request = new HttpRequest({
    hostname: `sts.${region}.amazonaws.com`,
    path: '/',
    method: 'GET',
    query: {
      Action: 'GetCallerIdentity',
      Version: '2011-06-15',
    },
    headers: {
      host: `sts.${region}.amazonaws.com`,
    },
  });

  const presignedRequest = await signer.presign(request, { expiresIn: 60 });
  return formatUrl(presignedRequest);
};

generateSTSPresignedURL('eu-west-1').then(console.log);

The presigned URL is a GetCallerIdentity request to STS. The generator API receives this, calls STS to validate it, and extracts the caller’s IAM identity. If the identity has permission, the request proceeds.

This pattern avoids sharing long-lived credentials. The GitHub Action’s IAM role (via OIDC) gets temporary credentials, signs the request, and the API validates it server-side.

The Custom GitHub Action

Directory Structure

set-service-weights/
├── action.yml          # Action metadata
├── index.js            # Main entry point
├── lib/
│   ├── generator.js    # API client
│   └── sigv4.js        # Signing logic
├── package.json
└── .node-version

action.yml

name: 'Set Traefik Service Weights'
description: 'Update Traefik weighted service configuration for blue/green deployments'

inputs:
  name:
    description: 'Service name (must match your ECS service name)'
    required: true
  settings:
    description: 'YAML list of tasksets and weights'
    required: true
  generator-url:
    description: 'Traefik generator API URL'
    required: true
  aws-region:
    description: 'AWS region for SigV4 signing'
    required: false
    default: 'eu-west-1'

outputs:
  result:
    description: 'Operation result message'

runs:
  using: 'node20'
  main: 'dist/index.js'

index.js

const core = require('@actions/core');
const YAML = require('yaml');
const { createService, deleteService, getService } = require('./lib/generator');

const buildTraefikConfig = (name, settings) => {
  const config = {
    http: {
      services: {
        [`${name}_service`]: {
          weighted: {
            services: []
          }
        }
      }
    }
  };

  settings.forEach(setting => {
    // Add to weighted services list
    config.http.services[`${name}_service`].weighted.services.push({
      name: `${name}-${setting.taskset}`,
      weight: setting.weight
    });

    // If URL provided, add load balancer config for this taskset
    if (setting.url) {
      config.http.services[`${name}-${setting.taskset}`] = {
        loadBalancer: {
          servers: [{ url: setting.url }]
        }
      };
    }
  });

  return config;
};

const validateInputs = (name, settings) => {
  if (!name || typeof name !== 'string') {
    throw new Error('Service name is required and must be a string');
  }

  if (!Array.isArray(settings) || settings.length === 0) {
    throw new Error('Settings must be a non-empty array');
  }

  const totalWeight = settings.reduce((sum, s) => sum + (s.weight || 0), 0);
  if (totalWeight !== 100) {
    core.warning(`Total weight is ${totalWeight}, not 100. This may cause unexpected traffic distribution.`);
  }

  settings.forEach((setting, index) => {
    if (!setting.taskset) {
      throw new Error(`Setting at index ${index} missing required field: taskset`);
    }
    if (typeof setting.weight !== 'number' || setting.weight < 0) {
      throw new Error(`Setting at index ${index} has invalid weight: ${setting.weight}`);
    }
  });
};

const run = async () => {
  try {
    const name = core.getInput('name', { required: true });
    const settingsYaml = core.getInput('settings', { required: true });
    const generatorUrl = core.getInput('generator-url', { required: true });
    const awsRegion = core.getInput('aws-region');

    // Parse and validate
    const settings = YAML.parse(settingsYaml);
    validateInputs(name, settings);

    // Build Traefik config
    const traefikConfig = buildTraefikConfig(name, settings);
    const traefikYaml = YAML.stringify(traefikConfig);

    core.info(`Generated Traefik config for ${name}:`);
    core.info(traefikYaml);

    // Push to generator API
    await createService(generatorUrl, `${name}_service`, traefikYaml, awsRegion);

    core.setOutput('result', `Successfully updated weights for ${name}`);
    core.info(`✅ Traffic weights updated for ${name}`);

  } catch (error) {
    core.setFailed(`Failed to update service weights: ${error.message}`);
  }
};

run();

lib/generator.js

const { generateSTSPresignedURL } = require('./sigv4');

const createService = async (baseUrl, namespace, configYaml, region) => {
  const authHeader = await generateSTSPresignedURL(region);

  const response = await fetch(`${baseUrl}/config/${namespace}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain',
      'Authorization': authHeader
    },
    body: configYaml
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Generator API returned ${response.status}: ${body}`);
  }

  return response;
};

const getService = async (baseUrl, namespace, region) => {
  const authHeader = await generateSTSPresignedURL(region);

  const response = await fetch(`${baseUrl}/config/${namespace}`, {
    method: 'GET',
    headers: {
      'Authorization': authHeader
    }
  });

  if (response.status === 404) {
    return null;
  }

  if (!response.ok) {
    throw new Error(`Generator API returned ${response.status}`);
  }

  return response.text();
};

const deleteService = async (baseUrl, namespace, region) => {
  const authHeader = await generateSTSPresignedURL(region);

  const response = await fetch(`${baseUrl}/config/${namespace}`, {
    method: 'DELETE',
    headers: {
      'Authorization': authHeader
    }
  });

  if (!response.ok) {
    throw new Error(`Generator API returned ${response.status}`);
  }

  return response;
};

module.exports = { createService, getService, deleteService };

lib/sigv4.js

const { SignatureV4 } = require('@aws-sdk/signature-v4');
const { Sha256 } = require('@aws-crypto/sha256-js');
const { HttpRequest } = require('@aws-sdk/protocol-http');
const { formatUrl } = require('@aws-sdk/util-format-url');
const { fromNodeProviderChain } = require('@aws-sdk/credential-providers');

const generateSTSPresignedURL = async (region) => {
  const signer = new SignatureV4({
    credentials: fromNodeProviderChain(),
    region,
    service: 'sts',
    sha256: Sha256,
  });

  const request = new HttpRequest({
    hostname: `sts.${region}.amazonaws.com`,
    path: '/',
    method: 'GET',
    query: {
      Action: 'GetCallerIdentity',
      Version: '2011-06-15',
    },
    headers: {
      host: `sts.${region}.amazonaws.com`,
    },
  });

  const presignedRequest = await signer.presign(request, { expiresIn: 60 });
  return formatUrl(presignedRequest);
};

module.exports = { generateSTSPresignedURL };

Using the Action

Basic Blue/Green

name: Deploy with Traffic Shift

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-traefik
          aws-region: eu-west-1

      # ... deploy green taskset ...

      - name: Shift 10% to Green
        uses: ./set-service-weights
        with:
          name: my-service
          generator-url: https://traefik-generator.moabukar.co.uk
          settings: |-
            - taskset: blue
              weight: 90
            - taskset: green
              weight: 10

      # ... run smoke tests ...

      - name: Shift 50% to Green
        uses: ./set-service-weights
        with:
          name: my-service
          generator-url: https://traefik-generator.moabukar.co.uk
          settings: |-
            - taskset: blue
              weight: 50
            - taskset: green
              weight: 50

      # ... more validation ...

      - name: Full Cutover to Green
        uses: ./set-service-weights
        with:
          name: my-service
          generator-url: https://traefik-generator.moabukar.co.uk
          settings: |-
            - taskset: blue
              weight: 0
            - taskset: green
              weight: 100

With Explicit URLs

If your tasksets have different endpoints:

- name: Configure Weighted Routing
  uses: ./set-service-weights
  with:
    name: api-gateway
    generator-url: https://traefik-generator.moabukar.co.uk
    settings: |-
      - taskset: blue
        weight: 80
        url: http://api-gateway-blue.internal.moabukar.co.uk:8080
      - taskset: green
        weight: 20
        url: http://api-gateway-green.internal.moabukar.co.uk:8080

This generates both the weighted service and the individual load balancer configs.

Rollback

- name: Emergency Rollback
  uses: ./set-service-weights
  with:
    name: my-service
    generator-url: https://traefik-generator.moabukar.co.uk
    settings: |-
      - taskset: blue
        weight: 100
      - taskset: green
        weight: 0

Traffic immediately shifts back to blue. The green taskset still runs (for debugging), but receives no traffic.

IAM Configuration

The GitHub Action needs an IAM role with permission to call STS GetCallerIdentity (for signing) and whatever permissions the generator API validates against.

Trust Policy (GitHub OIDC)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*"
        }
      }
    }
  ]
}

Permissions Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:GetCallerIdentity",
      "Resource": "*"
    }
  ]
}

The generator API does its own authorization based on the caller identity – you might restrict which roles can update which services.

Gotchas and Lessons Learned

1. Namespace naming must be consistent

The service name in the Action must match what your deployment pipeline uses. If ECS registers my-service-blue but your Action generates myservice-blue, routing breaks silently. We added validation and comments to enforce this.

2. Weights should sum to 100

Traefik doesn’t require this, but it’s confusing if they don’t. The Action warns if total weight ≠ 100.

3. The generator API should validate, not just store

Early versions accepted any YAML. Bad config took down routing. Now the API parses and validates before persisting.

4. Deleting non-existent namespaces should error

We found a bug where DELETE /config/nonexistent returned 200. Silent success on no-ops masks problems in pipelines.

5. SigV4 presigned URLs expire

The URL is valid for 60 seconds. If your pipeline has long steps between credential setup and API call, signing fails. Keep them close together.

6. YAML vs JSON

We chose YAML for the settings input because it’s more readable in workflow files. But be careful with YAML parsing edge cases – use a proper parser, not string manipulation.

7. Traefik reload latency

Config changes aren’t instant. Traefik polls the file provider (default 2s). During that window, traffic still goes to old weights. For critical cutovers, add a small delay or poll for confirmation.

Testing the Action

# .github/workflows/test-action.yml
name: Test Action

on:
  pull_request:
    paths:
      - 'set-service-weights/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ./set-service-weights/.node-version
          cache: npm
          cache-dependency-path: 'set-service-weights/package-lock.json'

      - name: Install Dependencies
        working-directory: ./set-service-weights
        run: npm ci

      - name: Lint
        working-directory: ./set-service-weights
        run: npm run lint

      - name: Test
        working-directory: ./set-service-weights
        run: npm test

      - name: Build
        working-directory: ./set-service-weights
        run: npm run build

Unit tests mock the API calls and verify config generation:

// __tests__/config.test.js
const { buildTraefikConfig } = require('../index');

test('generates valid weighted config', () => {
  const config = buildTraefikConfig('my-service', [
    { taskset: 'blue', weight: 90 },
    { taskset: 'green', weight: 10 }
  ]);

  expect(config.http.services['my-service_service'].weighted.services).toHaveLength(2);
  expect(config.http.services['my-service_service'].weighted.services[0]).toEqual({
    name: 'my-service-blue',
    weight: 90
  });
});

test('includes load balancer when URL provided', () => {
  const config = buildTraefikConfig('api', [
    { taskset: 'v1', weight: 100, url: 'http://api-v1:8080' }
  ]);

  expect(config.http.services['api-v1'].loadBalancer.servers[0].url).toBe('http://api-v1:8080');
});

Summary

Building a custom GitHub Action for Traefik traffic management gave us:

  • Declarative traffic control – weights defined in YAML, version-controlled, auditable
  • Pipeline integration – traffic shifts as deployment steps, not manual operations
  • Secure authentication – SigV4 with OIDC, no long-lived credentials
  • Instant rollback – one workflow dispatch to shift traffic back

The complexity is in the plumbing – SigV4 signing, API validation, Traefik config format. Once that’s solid, the developer experience is clean: define weights, run workflow, traffic shifts.


Building CI/CD tooling for traffic management or have questions about the implementation? Find me on LinkedIn.

Found this helpful?

Comments