Deployable Service CDK Construct
A CDK construct that reads citadel.yml and automatically generates complete ECS Fargate infrastructure.
The Magic: Zero-Drift Secret Management
The problem this solves: Traditionally, you define secrets in two places:
- Your deployment script (list of secret names)
- Your CDK code (mapping secrets to ECS task definition)
When you add INSTAGRAM_APP_ID, you must update both manually. Miss one → deployment fails.
The solution: This construct reads citadel.yml once and auto-generates the entire secrets map:
// AUTO-GENERATE SECRETS MAP from citadel.yml
secrets := make(map[string]awsecs.Secret)
for _, secretName := range cfg.Secrets {
param := awsssm.StringParameter_FromSecureStringParameterAttributes(
stack,
jsii.String(fmt.Sprintf("Param-%s", secretName)),
&awsssm.SecureStringParameterAttributes{
ParameterName: jsii.String(fmt.Sprintf("/%s/%s", cfg.Name, secretName)),
},
)
secrets[secretName] = awsecs.Secret_FromSsmParameter(param)
}
Result: Write the secret name once in citadel.yml, used everywhere. Zero drift. Zero manual sync.
Usage
1. Create citadel.yml
name: my-api
region: us-east-1
container:
port: 8080
cpu: 256
memory: 512
health_check_path: /health
health_check_grace_period: 60
environments:
dev:
account: "123456789012"
min_capacity: 1
max_capacity: 2
fargate_spot: true
secrets:
- DATABASE_URL
- API_KEY
- JWT_SECRET
2. Create CDK App
package main
import (
"github.com/ClusterBox/citadel/pkg/constructs/deployable"
"github.com/aws/aws-cdk-go/awscdk/v2"
"github.com/aws/jsii-runtime-go"
)
func main() {
defer jsii.Close()
app := awscdk.NewApp(nil)
env := app.Node().TryGetContext(jsii.String("env")).(string)
deployable.NewDeployableService(app, "my-api-stack", &deployable.DeployableServiceProps{
StackProps: awscdk.StackProps{
StackName: jsii.String("my-api-" + env),
Env: &awscdk.Environment{
Region: jsii.String("us-east-1"),
},
},
ConfigPath: "../citadel.yml",
Environment: env,
})
app.Synth(nil)
}
3. Deploy
cdk deploy --context env=dev
What It Creates
- VPC: Public-only (dev) or private+NAT (prod)
- ECR Repository: References existing
{name}-repo
- ECS Cluster: Fargate-enabled with Container Insights (prod only)
- IAM Roles: Execution role (with SSM access) + task role
- CloudWatch Logs:
/ecs/{name} with 1-week retention
- Task Definition: Auto-generated secrets from
citadel.yml
- Fargate Service: ALB + health checks + circuit breaker (or a Cloudflare tunnel sidecar, see below)
- CloudFront: Optional HTTPS termination (ALB environments only)
Environment-Specific Behavior
Dev
- Public subnets only (no NAT Gateway → $32/month savings)
- Container Insights disabled
- Fargate Spot enabled (70% cost savings)
- Public IP assignment
Prod
- Private subnets with NAT Gateway
- Container Insights (standard)
- On-demand Fargate (reliability)
- No public IPs
Adding a New Secret
Before (manual, error-prone):
- Add to
deploy.sh secret list
- Add to
cdk.go secrets map
- Miss one → broken deployment
After (automatic, zero-drift):
- Add to
citadel.yml secrets list
- Done. ✨
Advanced Configuration
Custom VPC Settings
vpc:
max_azs: 3
nat_gateways: 2 # prod only
Existing VPC
environments:
prod:
vpc_id: vpc-0123456789abcdef0 # look up this VPC instead of creating one
See the top-level README (vpc_id:).
CloudFront
cloudfront:
enabled: true
comment: "My API HTTPS distribution"
Cloudflare Tunnel ingress
An environment can skip the ALB (and CloudFront) and be reached through a
Cloudflare tunnel instead. That saves the ALB and its public IPs (~$24/month
per environment) and keeps WebSockets and streaming responses working.
environments:
dev:
ingress: cloudflare_tunnel # default: alb
Citadel adds a cloudflared sidecar (pinned image) to the task. It dials out
to Cloudflare, so the service has no load balancer and no inbound rule. Before
the first deploy:
- Create a remotely-managed tunnel in Cloudflare Zero Trust and give it a
public hostname whose service is
http://localhost:<container.port>.
- Store the tunnel token in SSM as a SecureString at
/<name>-<env>/CLOUDFLARE_TUNNEL_TOKEN. Only the sidecar receives it, as
TUNNEL_TOKEN, so don't list it under secrets:.
Switching an existing environment updates its ECS service in place; the ALB,
its target group and the CloudFront distribution are deleted, so clients must
move to the tunnel hostname.
Example Projects
See examples/legolas/ for a real-world production example.