Container Image Promotion Tool with OIDC Authentication
imgctl is a secure, gRPC-based tool for promoting container images between environments (dev → prod/int) with proper authentication and authorization via OIDC tokens.
- gRPC API: Fast, type-safe binary protocol with built-in code generation
- OIDC Authentication: SSH-signed JWT exchanged with Dex for OIDC tokens
- Group-Based Authorization: Fine-grained access control via group membership
- Container Image Promotion: Copy images between AWS ECR registries using
crane - Audit Logging: Comprehensive logging of all operations with user attribution
- Slack Integration: Optional notifications for image promotions
imgctl consists of two main components:
- Server: gRPC server that handles authenticated image promotion requests
- Client: CLI tool for interacting with the server
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐
│ User │─────▶│ SSH Agent│─────▶│ Dex │─────▶│ imgctl │
│ (CLI) │ │ (JWT) │ │(OIDC Token) │ (gRPC) │
└─────────┘ └──────────┘ └──────────┘ └─────────────┘
- Client creates SSH-signed JWT using user's SSH key
- JWT is exchanged with Dex for an OIDC token (with proper audience)
- OIDC token is sent to imgctl server via gRPC metadata
- Server validates token signature, issuer, audience, and group membership
- Upon successful auth, server executes the requested operation
go install github.com/nikogura/imgctl@latestOr build from source:
git clone https://github.com/nikogura/imgctl
cd imgctl
go build -o imgctl .Start the gRPC server:
export OIDC_ISSUER_URL="https://dex.example.com"
export OIDC_AUDIENCE="https://imgctl.example.com"
export OIDC_ALLOWED_GROUPS="engineering@example.com,ops@example.com"
imgctl server --bind-address 0.0.0.0:9999Server Environment Variables:
| Variable | Required | Purpose |
|---|---|---|
OIDC_ISSUER_URL |
Yes | Dex issuer URL |
OIDC_AUDIENCE |
Yes | Expected audience in OIDC tokens (this server's URL) |
OIDC_ALLOWED_GROUPS |
No | Comma-separated allowed groups (defaults to engineering@example.com) |
IMGCTL_REGISTRY_<NAME> |
Yes | Container registry URL for each named environment |
IMGCTL_SPA_<NAME> |
If using SPA | S3 bucket suffix for each named environment |
IMGCTL_ECR_REGION |
No | AWS region for ECR login (defaults to us-east-1) |
IMGCTL_DEFAULT_SOURCE |
No | Fallback source when client doesn't specify one |
IMGCTL_SLACK_WEBHOOK_URL |
No | Slack webhook for promotion notifications |
imgctl promote myapp:v1.0.0 --source dev --destination prodIf compile-time defaults are baked in (see Building with Compile-Time Defaults), this simplifies to:
imgctl promote myapp:v1.0.0For static assets deployed via CDN (single-page applications, static sites, etc.), imgctl promotes versioned S3 paths between environment buckets using aws s3 sync:
imgctl promote spa:my-dashboard:v0.1.42 --source dev --destination prodThe spa: prefix tells imgctl to treat the ref as an S3 asset rather than a container image. The format is spa:<name>:<version>. Source and destination map to S3 bucket suffixes configured via IMGCTL_SPA_<NAME> on the server. For example, with IMGCTL_SPA_DEV=dev-01 and IMGCTL_SPA_PROD=prod-01:
- Source:
s3://my-dashboard-dev-01/v0.1.42/ - Destination:
s3://my-dashboard-prod-01/v0.1.42/
Client Flags:
--server,-s- gRPC server address (default:IMGCTL_SERVERenv var orlocalhost:9999)--source- Source environment name--destination,-d- Destination environment name--ref,-r- Artifact ref to promote (can also be first positional arg)--dex-url- Dex issuer URL (orDEX_URLenv var)--client-id- OAuth2 client ID (orIMGCTL_CLIENT_IDenv var)--client-secret- OAuth2 client secret (orIMGCTL_CLIENT_SECRETenv var)--pubkey-file,-f- SSH public key file (default:~/.ssh/id_ed25519.pub)--username,-u- Username for authentication (orKUBECTL_SSH_USERenv var)--show-token- Display the OIDC token (for debugging)
Client Environment Variables:
| Variable | Purpose |
|---|---|
IMGCTL_SERVER |
Default gRPC server address |
DEX_URL |
Dex issuer URL for OIDC authentication |
IMGCTL_CLIENT_ID |
OAuth2 client ID |
IMGCTL_CLIENT_SECRET |
OAuth2 client secret |
KUBECTL_SSH_USER |
Username for SSH authentication |
IMGCTL_DEFAULT_SOURCE |
Default --source value |
IMGCTL_DEFAULT_DESTINATION |
Default --destination value |
Test your OIDC authentication without performing any operations:
imgctl auth-check \
--server imgctl.example.com:9999 \
--dex-url https://dex.example.comimgctl uses user-defined environment names for source and destination. The names are arbitrary — call them whatever makes sense for your organization. The server maps names to concrete registry URLs and S3 bucket suffixes via environment variables.
Set IMGCTL_REGISTRY_<NAME> for each environment:
export IMGCTL_REGISTRY_DEV="123456789012.dkr.ecr.us-east-1.amazonaws.com"
export IMGCTL_REGISTRY_PROD="234567890123.dkr.ecr.us-east-1.amazonaws.com"
export IMGCTL_REGISTRY_INT="345678901234.dkr.ecr.us-east-1.amazonaws.com"
export IMGCTL_ECR_REGION="us-east-1"The client uses --source dev --destination prod and the server resolves those names to the corresponding registry URLs.
Set IMGCTL_SPA_<NAME> for each environment. The value is the bucket suffix — bucket names are constructed as <spa-name>-<suffix>:
export IMGCTL_SPA_DEV="dev-01"
export IMGCTL_SPA_PROD="prod-01"
export IMGCTL_SPA_STAGE="stage-01"With these, imgctl promote spa:my-dashboard:v0.1.42 --source dev --destination prod copies s3://my-dashboard-dev-01/v0.1.42/ to s3://my-dashboard-prod-01/v0.1.42/.
export IMGCTL_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"Omit to disable notifications.
If the client doesn't send a source environment, the server falls back to IMGCTL_DEFAULT_SOURCE:
export IMGCTL_DEFAULT_SOURCE="dev"The server does not have a default destination — if the client doesn't specify one, the request fails.
Organizations can bake in defaults at build time via ldflags so users don't have to configure environment variables or pass flags for common operations.
| Variable | ldflags Path |
Purpose |
|---|---|---|
defaultSource |
github.com/nikogura/imgctl/cmd.defaultSource |
Default --source on the client |
defaultDestination |
github.com/nikogura/imgctl/cmd.defaultDestination |
Default --destination on the client |
defaultClientID |
github.com/nikogura/imgctl/cmd.defaultClientID |
OAuth2 client ID for Dex |
defaultClientSecret |
github.com/nikogura/imgctl/cmd.defaultClientSecret |
OAuth2 client secret for Dex |
go build -ldflags "\
-X github.com/nikogura/imgctl/cmd.defaultSource=dev \
-X github.com/nikogura/imgctl/cmd.defaultDestination=prod \
-X github.com/nikogura/imgctl/cmd.defaultClientID=your-client-id \
-X github.com/nikogura/imgctl/cmd.defaultClientSecret=your-client-secret" \
-o imgctl .With these defaults baked in, a user just types:
imgctl promote myapp:v1.0.0and it promotes from dev to prod with the correct Dex credentials.
For each configurable value, the precedence is (highest to lowest):
- CLI flag (
--source,--destination,--client-id, etc.) - Environment variable (
IMGCTL_DEFAULT_SOURCE,IMGCTL_DEFAULT_DESTINATION,IMGCTL_CLIENT_ID, etc.) - Compile-time default (baked in via
ldflags)
The GitHub Actions workflow (.github/workflows/ci.yml) handles:
- Test + Lint on every push to
mainand on PRs tomain - Auto-versioning by incrementing the patch version from the last git tag
- Cross-compilation of release binaries (darwin/amd64, darwin/arm64, linux/amd64)
- Auto-tagging and GitHub Release creation on merge to
main
To bake in defaults during CI, set these as GitHub repository secrets:
IMGCTL_DEFAULT_CLIENT_IDIMGCTL_DEFAULT_CLIENT_SECRET
Source and destination defaults can be added to the build step in the workflow directly since they aren't secret.
The gRPC service is defined in pkg/imgctl/proto/imgctl.proto:
service ImgCtl {
rpc Promote(PromoteRequest) returns (PromoteResponse) {}
rpc AuthCheck(AuthCheckRequest) returns (AuthCheckResponse) {}
}To regenerate after modifying the .proto file:
make proto- Go 1.24+
- Protocol Buffer compiler (
protoc) protoc-gen-goandprotoc-gen-go-grpcpluginscraneCLI tool (for image copying)- AWS CLI (for ECR authentication)
namedreturnslinter (go install github.com/nikogura/namedreturns@latest)golangci-lint
make testmake lintmake build- Authentication: Uses SSH-signed JWTs exchanged for OIDC tokens via Dex
- Authorization: Group-based access control enforced at the server
- Audit Logging: All operations are logged with user attribution
- Token Validation: Server validates OIDC token signature, issuer, audience, and expiration
- JWKS Caching: Public keys are cached for 5 minutes to reduce load on Dex
Production Recommendations:
- Enable TLS for gRPC connections (currently uses insecure credentials for development)
- Deploy behind a load balancer with proper TLS termination
- Configure firewall rules to restrict access to the gRPC port
- Rotate Dex signing keys regularly
- Monitor and alert on authentication failures
- Use separate OIDC audiences for different environments
Apache License 2.0. See LICENSE for details.
- Nik Ogura
- Built on kubectl-ssh-oidc for SSH-based OIDC authentication
- Uses crane for container image operations