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.