citadel

module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: MIT

README

Citadel

A declarative CDK deployment framework for AWS ECS/Fargate.

Overview

Citadel makes container deployments as simple as the Serverless Framework experience — but targeting ECS/Fargate instead of Lambda. You declare your app once in a citadel.yml. The tool handles SSM secret syncing, CDK infrastructure deployment, Docker build/push, and ECS service rollout in a single command.

citadel deploy --env dev --deploy-infra

The Problem

Deploying ECS applications involves managing configuration across multiple files:

  • Secret lists in deployment scripts
  • Duplicate secret definitions in CDK code
  • Infrastructure config scattered across multiple files

The Solution

Single source of truth: citadel.yml defines everything about your deployment.

Both the CLI tool and CDK constructs read from the same file. Write a secret name once, use it everywhere.

Installation

# Go
go install github.com/ClusterBox/citadel/cmd/citadel@latest

# From source
git clone https://github.com/ClusterBox/citadel.git
cd citadel
make install

Usage

Full deployment pipeline
citadel deploy --env dev --deploy-infra
Start a project
citadel init --ecs       # or --lambda; prompts for anything not passed as a flag
citadel init --lambda --yes --account 123456789012   # non-interactive

init writes a commented citadel.yml and a .citadel/ directory next to it. If citadel.yml already exists it is left alone and only .citadel/ is created (pass --force to regenerate the config). citadel.yaml is accepted when citadel.yml is absent.

The .citadel/ directory
Path Committed Contents
.citadel/project.yml yes project id, name, runtime, citadel version that created it
.citadel/state/<env>.json no last deploy of each env from this checkout (shown by citadel status)
.citadel/runs/<id>/ no one folder per deploy: run.json + a log file per step (newest 50 kept)

citadel deploy creates .citadel/ automatically if it is missing. Dry runs record nothing.

citadel always excludes .citadel/ from the Docker build context it sends to the daemon, the same way it excludes .git/. Tools that hash or copy the repo themselves instead of going through citadel deploy — e.g. a CDK DockerImageFunction/fromImageAsset pointing at the repo root — should add .citadel/ to their own .dockerignore.

Deploy from GitHub Actions
on:
  push:
    branches: [main, development]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
    permissions:
      contents: read
    concurrency: { group: citadel-${{ github.ref_name }}, cancel-in-progress: false }
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - uses: ClusterBox/citadel/action@v0.3.0
        with:
          deploy-infra: true
          env-file: ${{ secrets.CITADEL_ENV_FILE }}

main deploys prod and development deploys dev (override with environment or branch-map). See action/README.md.

Pipeline

With no pipeline: key, citadel deploy runs its built-in sequence (ssm-sync, build, cdk, deploy, config-sync (lambda only), wait) exactly as before. To customize the order, mix in shell commands, run a one-off ECS task, or add a health check, declare the deploy as an explicit list of steps. pipeline: needs citadel v0.4.0 or later (GitHub Action: ClusterBox/citadel/action@v0.4.0 or later); older versions ignore the key and run the default pipeline.

pipeline:
  - uses: citadel/ssm-sync
  - name: test
    run: npm test
  - uses: citadel/build
  - name: migrate
    task: npx prisma migrate deploy
    timeout: 15m
  - uses: citadel/cdk
  - uses: citadel/deploy
  - uses: citadel/wait
  - name: smoke
    http: https://api-${env}.example.com/health
    rollback_on_failure: true

Each step gets its own log under .citadel/runs/<id>/ and its own line in run.json. Validation runs when the config loads (citadel deploy, citadel status, citadel init, and the GitHub Action all get it), so a bad pipeline is rejected before any AWS call. Every error names the step's position and name: pipeline[<i>] "<name>": <problem>. An unknown key in a step (e.g. a misspelled environment: or rollback-on-failure:) is an error too.

Step kinds. Exactly one of uses, run, task, http must be set per step.

Field Applies to Default Meaning
uses built-in — citadel/ssm-sync, citadel/build, citadel/cdk, citadel/deploy, citadel/config-sync, citadel/wait
run shell — Command string
shell run bash -euo pipefail -c Interpreter, given as a list, e.g. [sh, -c]
working_directory run the config's directory Relative to the config's directory; ${var}s are expanded
env run — map[string]string; values get ${var} substitution
task task — A string, run as sh -c <string>, or a YAML list passed as the command as-is
container task the container whose image repository is the service's ECR repository Which container gets the command override
http http — URL
expect_status http 200 Must be 100–599
retries http 10 Total attempts; at least 1
interval http 6s Go duration, between attempts
request_timeout http 5s Go duration, per attempt
rollback_on_failure http false Only valid on an http step placed after citadel/deploy
name all built-ins: the action name (e.g. ssm-sync) Required for run/task/http; must be unique and match ^[a-z0-9][a-z0-9-]*$ (it becomes the step's log file name); may not be a built-in name or rollback
envs all all environments Only run this step in these environments; others print ⏭️ Skipping <name> (not for <env>)
continue_on_error all false The step's failure is recorded but the run continues. Not allowed on citadel/build, citadel/cdk or citadel/deploy, nor together with rollback_on_failure
timeout run, task 30m Go duration

Variables, expanded in run, env values, working_directory, task, container and http:

Variable Value
${env} the environment being deployed
${name} the project name
${image} the pushed image URI (empty before citadel/build runs)
${sha} the short git SHA
${region} the config's region
${account} the environment's account

Only a lowercase ${word} is a citadel variable — an unknown one is a config load error. Anything else, such as ${HOME} or $PATH, passes through untouched for the shell (or the receiving server, for http) to resolve.

run: steps also get the variables as environment variables: CITADEL_ENV, CITADEL_NAME, CITADEL_IMAGE, CITADEL_SHA, CITADEL_REGION and CITADEL_ACCOUNT. They win over a key of the same name in the step's env:.

Ordering rules, enforced at load time:

  • citadel/build must come before citadel/cdk, citadel/deploy, and every task: step.
  • citadel/cdk must come before citadel/deploy (when present — it's optional).
  • citadel/deploy, citadel/config-sync and citadel/wait may each appear at most once.
  • citadel/config-sync and citadel/wait must come after citadel/deploy.
  • When citadel/build has envs:, citadel/cdk, citadel/deploy and every task: step need the image it pushes, so each must list envs: that are a subset of build's (an omitted envs: means every environment and is rejected).
  • A pipeline without citadel/build or citadel/deploy is valid, e.g. one that only runs migrations.

Built-ins keep their CLI flag gates: citadel/ssm-sync syncs from the env file given by --env-file (default .env) and skips with --skip-ssm or --env-file ""; citadel/cdk runs only with --deploy-infra; citadel/config-sync skips with --skip-config; citadel/wait runs only with --wait. citadel/config-sync is valid only for the lambda runtime; task: is valid only for the ecs runtime.

Because citadel/wait only runs with --wait, an http: check placed after it runs as soon as the rollout starts when --wait is not given, and may probe the old tasks that are still serving traffic.

Failure handling:

  • A step with continue_on_error: true that fails prints a warning and stays failed in run.json, but the run keeps going.
  • An http step with rollback_on_failure: true that fails instead runs an automatic rollback, recorded as its own rollback step: ECS rolls the service back to the task-definition revision it ran before this deploy; Lambda rolls the function back to its previous image. The snapshot of "what ran before" is taken right before the first citadel/cdk or citadel/deploy step that actually runs — but only when some step in the pipeline has rollback_on_failure: true, so a pipeline without one makes no extra AWS call. If the snapshot could not be taken, the deploy still proceeds; a rollback attempted after that has nothing to restore and fails with "cannot roll back: no snapshot of the previous deployment". A deploy cancelled with Ctrl-C/SIGTERM never rolls back: it prints ↩️ Not rolling back: deploy was cancelled and stops. If the revision to restore was deregistered in the meantime (e.g. by a CDK deploy), ECS rollback registers an identical copy and uses that.
  • Otherwise a step's failure stops the pipeline; the run, the state file and the deploydb row all record it.

task: is ECS-only. It runs a one-off task in the freshly built image, on the service's own network (subnets, security groups, launch type), overriding the command on the target container. It needs ecs:RunTask, ecs:DescribeTasks, ecs:StopTask, logs:GetLogEvents, and iam:PassRole on the task and execution roles. rollback_on_failure needs ecs:DescribeServices/ecs:DescribeTaskDefinition/ecs:UpdateService (plus ecs:RegisterTaskDefinition when the old revision was deregistered) (ECS) or lambda:UpdateFunctionCode/lambda:GetFunction (Lambda) — see action/README.md for the full list.

citadel deploy --dry-run prints the plan without changing anything (it still reads from AWS): each built-in prints its existing dry-run line, run: prints [dry-run] Would run: <cmd>, task: prints [dry-run] Would run task: <cmd>, and http: prints [dry-run] Would check: <url>. Nothing is recorded in .citadel/runs/.

citadel-logs daemon

Citadel ships a separate always-on binary, citadel-logs, that watches CloudWatch log groups for every registered service and surfaces 500-class errors at http://localhost:5500/logs.

It works across runtimes:

  • runtime: ecs (default) NestJS backends
  • runtime: lambda — clusterbox Go Lambdas like smaug. Requires a lambda: { functionName: ... } block.
Run it as a service (Linux)
# 1. Install both binaries (citadel + citadel-logs)
make install

# 2. Register a repo
cd ~/Documents/github/my-backend
citadel logs-daemon register --env dev

# 3. Start the daemon as a systemd user service
citadel logs-daemon start

# 4. Open the dashboard
open http://localhost:5500/logs

citadel logs-daemon start installs a systemd --user unit (~/.config/systemd/user/citadel-logs.service), enables it, and turns on user lingering so it survives reboots and starts before you log in. It captures AWS_PROFILE / AWS_REGION from your shell (override with --profile / --region). The daemon stores its SQLite db under ~/.local/share/citadel/citadel-logs.db and serves the dashboard on 127.0.0.1:5500.

Other commands:

citadel logs-daemon status            # is it running?
citadel logs-daemon logs              # tail the journal (-n N, --no-follow)
citadel logs-daemon restart           # re-install unit + restart
citadel logs-daemon stop [--disable]  # stop (and optionally disable autostart)

On non-Linux platforms, use the Docker path instead: docker compose -f docker-compose.logs.yml up -d.

Roadmap

  • Project architecture
  • CLI scaffolding
  • Config parser
  • SSM secret sync
  • Docker build/push
  • ECS deployment
  • CDK construct library

Configuration

queues: — SQS access (optional)

Grants the ECS task role least-privilege access to existing SQS queues. Queues are split by intent:

queues:
  consume:
    - arn:aws:sqs:us-east-1:123456789012:incoming
  produce:
    - arn:aws:sqs:us-east-1:123456789012:outgoing
  • consume queues are granted sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes, and sqs:ChangeMessageVisibility.
  • produce queues are granted sqs:SendMessage and sqs:GetQueueAttributes.

A queue ARN may appear in both lists if the service both reads and writes it.

An ARN may contain {env} in its queue name (for example …:settle-{env}), which becomes the environment being deployed, so dev is never granted prod's queue. {env} elsewhere in the ARN, and any other {...}, is rejected. Quote such values in a YAML flow list.

Citadel does not create the queues — they must already exist.

iam: — runtime SSM reads (optional)

Lets the application read SSM parameters at runtime, beyond the boot secrets under /<name>-<env>/ (which the execution role already injects):

iam:
  ssm_read:
    - /smaug/{env}/psp

Each prefix grants the task role ssm:GetParameter and ssm:GetParameters on every parameter under it (arn:aws:ssm:*:*:parameter/smaug/<env>/psp/*). {env} becomes the environment being deployed; any other {...} is rejected. Prefixes must start with /, may not be / alone, and may not contain wildcards. In a YAML flow list, quote values that contain {env} (ssm_read: ["/smaug/{env}/psp"]). Parameters encrypted with the default aws/ssm key need no KMS grant.

cache: — ElastiCache Serverless Valkey (optional)
cache:
  engine: valkey
  serverless: true

Creates an ElastiCache Serverless cache named <name>-<env> in the same subnets as the tasks (public in dev, private in other environments), behind a security group that admits only the service's tasks on 6379 (primary endpoint) and 6380 (reader endpoint). The endpoint (host:port) is injected as the plain env var VALKEY_ENDPOINT, so do not also list it under secrets: or env:. Serverless caches require TLS. Only engine: valkey with serverless: true is supported, on the ecs runtime. The stack also outputs CacheEndpoint.

The cache is ephemeral: it and its data are deleted with the stack or when the cache: block is removed, with no final snapshot. Because its name is fixed, changes that make CloudFormation replace it (for example raising vpc.max_azs, which changes its subnets) fail the deploy and roll back; to apply one, remove the cache: block, deploy, then add it back.

vpc_id: — run in an existing VPC (optional, per environment)
environments:
  prod:
    account: "454066810976"
    vpc_id: vpc-01f19f79f65a87711   # e.g. legolas-prod's VPC

By default each environment creates its own VPC, plus a NAT gateway and an Elastic IP in prod (about $36/month), and each VPC counts against the region's VPC and internet gateway quotas (5 each by default). With vpc_id the environment instead runs in a VPC that already exists, looked up at synth time, and creates none of these. Tasks and the cache use the same subnets as before: public in dev, private-with-egress elsewhere, so the VPC needs CDK-tagged (aws-cdk:subnet-type) subnets of those kinds, as any citadel-created VPC has. The environment's account is required, and vpc.* settings are ignored for it. A cache: in such an environment is named <name>-<env>-<first 8 hex digits of the VPC ID>, so moving an environment with a cache onto an existing VPC replaces the cache (its data is lost) under a new name instead of failing on the old one.

The VPC's owner can no longer delete it while this service runs there, so remove this service (or move it) before tearing that stack down. Moving an existing environment onto another VPC updates its service in place (new subnets and security group, a rolling deployment) and deletes its old VPC.

Releasing

Push a tag vX.Y.Z on main. .github/workflows/release.yml runs the tests and publishes citadel_<version>_<os>_<arch>.tar.gz archives plus checksums.txt. The GitHub Action at the same tag (ClusterBox/citadel/action@vX.Y.Z) installs exactly that release. make release-snapshot builds the archives locally.

License

MIT

Directories

Path Synopsis
cmd
citadel command
citadel-logs command
citadel-logs is the always-on daemon that watches CloudWatch log groups for every registered clusterbox service and surfaces 500-class events at http://localhost:5500/logs.
citadel-logs is the always-on daemon that watches CloudWatch log groups for every registered clusterbox service and surfaces 500-class events at http://localhost:5500/logs.
internal
aws
deploydb
Package deploydb is the SQLite persistence layer for citadel deployment history.
Package deploydb is the SQLite persistence layer for citadel deployment history.
env
ingest/parsers
Package parsers contains per-runtime CloudWatch log-line parsers used by the citadel-logs ingest loop.
Package parsers contains per-runtime CloudWatch log-line parsers used by the citadel-logs ingest loop.
initcmd
Package initcmd implements `citadel init`: it gathers values from flags, the environment and prompts, renders a commented citadel.yml from an embedded per-runtime template, and creates the project's .citadel/ dir.
Package initcmd implements `citadel init`: it gathers values from flags, the environment and prompts, renders a commented citadel.yml from an embedded per-runtime template, and creates the project's .citadel/ dir.
logsdb
Package logsdb is the SQLite persistence layer for the citadel-logs daemon.
Package logsdb is the SQLite persistence layer for the citadel-logs daemon.
project
Package project owns the per-project .citadel/ directory that lives next to citadel.yml: committed metadata (project.yml), the last deploy per env (state/), and per-deploy step logs (runs/).
Package project owns the per-project .citadel/ directory that lives next to citadel.yml: committed metadata (project.yml), the last deploy per env (state/), and per-deploy step logs (runs/).
registry
Package registry loads the list of services the citadel-logs daemon watches.
Package registry loads the list of services the citadel-logs daemon watches.
webui
Package webui serves the citadel-logs dashboard at localhost:5500.
Package webui serves the citadel-logs dashboard at localhost:5500.
pkg

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL