Self-hosted platform for standing up and running software factories on AWS: complete DevSecOps environments with secure delivery pipelines, supply-chain controls, compliance evidence and multi-tenant governance — in minutes, not months.
Two targets: EC2 (a single Minikube instance) or EKS (managed Kubernetes, Auto Mode).
infra provision is the recommended entry point, wrapping the bin/setup.sh scripts that remain the source of truth.CREATE_COMPLETE is not the same as the platform being reachable./dashboard/observability page, seeded at cold start as system-org public dashboards — not a separate tool to stand up.This guide is for operators standing up Pipeline Builder on AWS. It covers both targets in either public (internet-facing ALB) or private (internal ALB, the default) mode, plus email via SES, platform initialization, execution reporting and drift detection.
Both targets deploy the full stack: app services, databases, observability (Prometheus + Loki, surfaced in-app) and admin tools.
Five dashboards — Platform Overview, Plugin Builds, Queue Health, Registry Activity, Audit Activity — are seeded into the database at platform cold start as public rows owned by the system org (org_id = the configured SYSTEM_ORG_ID, default 000000000000000000000001), so they appear automatically for any logged-in org at /dashboard/observability/<id>.
| Who | Sees |
|---|---|
| Org member | Plugin Builds, and the org-scoped part of Platform Overview |
| Org admin | The above plus Audit Activity, reading the MongoDB audit trail scoped to their org |
| System admin | Everything, including Queue Health and Registry Activity |
Panels backed by fleet-wide queries are shown only to system admins, and a dashboard with no panel the caller can render is hidden entirely. Audit Activity also has a dedicated page at /dashboard/observability/audit-activity.
--domain and --hosted-zone-id.pipeline-manager infra provision is recommended; bin/setup.sh or raw CloudFormation work too. It requests a DNS-validated ACM cert and fronts the private compute with an ALB.https://<your-domain> — publicly resolvable in public mode, in-VPC only in private mode.infra provision, or the target’s bin/setup.sh. Use --plan first to see what will happen.--email is the default on both AWS targets and provisions the whole SES path; --no-email opts out. See Email (SES).infra provision) – The recommended way to install the platform--no-email to skip)audit stacks) – Reconcile registry vs live CloudFormation| EC2 | EKS | |
|---|---|---|
| Runtime | Minikube on EC2 | EKS Auto Mode (Karpenter-scaled EC2 nodes) |
| Infra | 1 CloudFormation stack | eksctl cluster + Kubernetes manifests |
| TLS | ACM cert at the ALB | ACM cert at the ALB Ingress |
| Public surface | ALB only (instance private) | ALB Ingress only (nodes private) |
| Storage | hostPath PVCs on EBS | EBS (RWO) + EFS (RWX) via CSI |
| Scaling | Vertical (instance resize) | Horizontal (Karpenter nodes + pod autoscaling) |
| Cost | ~$610-680/mo at the default m5.4xlarge; ~$145-215 on t3.xlarge | ~$850-950/mo measured, 24/7 |
| Best for | Dev/staging | Production |
infra provision)The recommended way to install the platform is pipeline-manager infra provision. It picks the target, runs prerequisite checks (AWS CLI + working credentials for EC2/EKS — plus kubectl, openssl, and envsubst for EKS (eksctl is auto-installed by setup.sh); Docker etc. for local), assembles the exact, validated setup.sh command (secrets masked, missing inputs reported rather than guessed), prints the plan, and then deploys it — gated by confirmation prompts (--yes to auto-accept for CI; --json prints the plan and runs nothing). With an AI key configured it also parses a natural-language goal and diagnoses CloudFormation failures.
npm install -g @pipeline-builder/pipeline-manager
# Deploy (shows the plan, then confirms; add --yes for non-interactive CI):
pipeline-manager infra provision --target eks \
--domain pipeline.example.com --hosted-zone-id Z123 --ghcr-token ghp_xxx --email
# Inspect the plan as JSON without running anything:
pipeline-manager infra provision --target eks --json \
--domain pipeline.example.com --hosted-zone-id Z123 --ghcr-token ghp_xxx --email
# Or describe the goal (needs an AI key — see Environment Variables):
pipeline-manager infra provision --prompt "deploy to EKS in us-east-1 with email enabled"
# Diagnose a failed deploy:
pipeline-manager infra provision --target eks --diagnose ./stack-events.txt
Always deploys (gated).
infra provisionchecks, assembles, prints the plan, and runs the deploy — it refuses on failed prerequisites or missing inputs, asks for confirmation before deploying (--yesauto-accepts for CI), streams the deploy to your terminal, then verifies/health+/readyon the application URL. On the AWS targets the deploy self-inits (EC2 on first boot; EKS insetup.sh’s final phase), soinfra provisionsurfaces it rather than running it separately; on local/minikubeinfra provisionrunsinit-platformfor you.--jsonis the only non-executing mode — it prints the plan and exits (for tooling).On failure it troubleshoots. It matches known CloudFormation signatures and prints the likely cause + fix — and for a few it can auto-fix and retry (e.g. an existing SES identity → re-run with
--skip-ses-identity; an ACM/DNS-propagation timeout → resume). Retries are gated and bounded by--retries <n>(default 1; the scripts are idempotent so a re-run resumes). With an AI key it adds a free-form diagnosis on top. When SES is enabled, a successful deploy prints DKIM/sandbox next-steps.Flags:
--yesauto-approves (CI),--retries <n>auto-fix/retry budget,--init <mode>controls post-deploy initialization (autodefault /manual/skip— see below),--skip-ses-identityfor an already-verified SES domain,--stack-name <name>(EC2) /--cluster-name <name>(EKS) to deploy/manage a second environment.Init mode (
--init <mode>). One flag controls how the platform initializes after deploy:
auto(default) —init-platformruns once the platform is up, registering the admin (with the default password) and loading plugins/compliance/samples. The AWS targets self-run it as part of the deploy: EC2 on first boot (UserData → on the box as theminikubeuser — watch withaws ssm start-session … && sudo tail -f /var/log/user-data.log); EKS insetup.sh’s final phase, reaching the cluster over akubectl port-forward. local/minikube run it frominfra provision.manual— don’t init;infra provisionsurfaces the exact step for you to run yourself (do this to set real admin credentialsPLATFORM_IDENTIFIER/PLATFORM_PASSWORDinstead of the default).skip— don’t initialize at all (no register, no loads).Teardown. Add
--teardownto remove a deployment.local/minikubestop the stack (on-disk / PVC data persists). EC2 DELETEs its CloudFormation stack and EKS runsbin/shutdown.sh(deletes the cluster, EFS, ACM cert + Route 53 alias) — both irreversible — so the destructive path is gated harder than deploy: you must type the resource id to confirm (a y/N is too easy to fat-finger), and--yesalone does not bypass it — only--forcedoes (for CI). When you pass a custom--stack-name <name>(EC2) or--cluster-name <name>(EKS), the confirmation binds to that name — you type the stack/cluster name, not the target id, so a wrong name can’t be confirmed by habit. The region comes from--region/AWS_REGION. As always,bin/shutdown.sh(local/minikube/EKS) andaws cloudformation delete-stack(EC2) can be run directly.# Teardown — prints the destroy plan, then prompts (type "eks" to confirm): pipeline-manager infra provision --target eks --teardownBootstrap a fresh machine (
--repo). Without a checkout,--repogit-clones the platform repo first and runs from it. The clone is sparse + partial —git clone --filter=blob:none --no-checkout+ conesparse-checkout(git ≥ 2.27; older git falls back to a full clone) — so it materializes only the deploy folders the selected target + options need, not the whole repo (packages/,api/,frontend/, … are never downloaded). The common base is justdeploy/bin; each target adds its own folder (deploy/local/docker,deploy/local/minikube— self-contained —deploy/aws/ec2,deploy/aws/eks), and each post-install load adds its folder. Re-syncs are additive (sparse-checkout add), so one--workdircan accumulate multiple targets. Override with--repo <url>,--ref <branch|tag>,--workdir <dir>. (--refis a branch/tag; arbitrary SHAs may not fetch under the shallow clone.)Post-install steps. After deploy + health,
infra provisionregisters the admin (non-interactive with--admin-email/--admin-password, which setPLATFORM_IDENTIFIER/PLATFORM_PASSWORD) and runs opt-in loads — each also pulls its folder into the sparse clone:--with-plugins(build + load plugins; addsdeploy/plugins+deploy/codebuild),--with-compliance(deploy/compliance),--with-samples(deploy/samples), or--with-all. Also--build-bootstrap(CodeBuild bootstrap image),--with-smoke-test(read-only API check),--with-events(EC2/EKS event ingestion — a four-step bundle: threeinfra store-tokenruns writing the platform,registry:pushandreporting:ingestservice-account keys to Secrets Manager at thepipeline-builder/{orgId}/…pattern, theninfra setup-events --scoped-ingestdeploying the EventBridge → SQS → Lambda that reads the ingest key; all pull AWS creds from the standard env /~/.awschain, and needPLATFORM_PASSWORDfor the step-up each key write requires), and repeatable--post-step "<cmd>". The default is register-only (minimal clone); the loads are deterministic + idempotent, so re-running with more options just layers them on. On the AWS targets these loads run deploy-side by default (soinfra provisiondoesn’t prompt for them locally) — EC2 on first boot, EKS insetup.sh’s final phase over akubectl port-forward. Pass--init manualto drive them yourself.# Fresh box → sparse-clone just deploy/bin + deploy/local/docker, deploy, register, load samples: pipeline-manager infra provision --target docker --repo --with-samples --yes \ --admin-email admin@acme.com --admin-password 's3cret'
The underlying bin/setup.sh scripts remain the source of truth and can always be run directly — the rest of this guide documents them.
On EKS, bin/setup.sh runs ten phases and accepts --from-phase /
--to-phase / --only-phase, so a re-deploy does not have to re-walk the ~25
minutes of cluster, EFS and ACM work in phases 1-3. bin/startup.sh is the
shorthand for phases 4-8 — secrets, IAM, operators, workloads and the Route 53
alias — which is what a config change, a new image tag or an edited manifest
actually needs, and it gives EKS the startup.sh the EC2 and minikube targets
already have. It does not run init-platform.sh or the smoke checks; both are
separate scripts and neither follows from re-applying config.
Both AWS targets run an Istio ambient service mesh (STRICT mTLS + identity-based
L4 authorization between all services). bin/setup.sh (EKS) / bin/startup.sh (EC2)
install it right after KEDA; policies live in k8s/istio.yaml. See
Service Mesh for the full model. AWS-target specifics:
:15008 (cross-node
mTLS) plus istiod xDS :15012 / webhook :15017. If AWS’s Auto Mode guidance pins
different CNI conf/bin dirs, pass them via --set values.cni.cniConfDir/cniBinDir
in setup.sh. Validate capture across all nodes and under a Karpenter scale-up.k8s/resource-limits.yaml). To run a t3.xlarge
instead, deploy with LEAN=1 — it drops the
optional observability/admin services and single-replicas every workload so the core
stack + mesh fits. Set it at launch (CFN Lean param): LEAN=1 deploy/aws/ec2/bin/setup.sh
(or pipeline-manager infra provision --target ec2 --lean --instance-type t3.xlarge),
or on the box: LEAN=1 sudo -E bash deploy/aws/ec2/bin/startup.sh (-E preserves the
env through sudo). See Service Mesh: LEAN mode.nginx:8080,
which is carved out of STRICT (PERMISSIVE) — the ALB health check on :8080/health
rides the same carve-out.redis-sentinel.yaml); clients reach
redis-sentinel:26379 + redis:6379, both meshed.Either target (EC2 or EKS) deploys in one of two modes. Both put the compute in private subnets and terminate TLS at an ALB with a publicly-trusted, DNS-validated ACM cert — so both require --domain + --hosted-zone-id (the public Route 53 zone is where ACM validates the cert). The mode flips only the ALB scheme and the DNS record:
private (inside-AWS-only, default) |
public |
|
|---|---|---|
| ALB scheme | internal, private subnets | internet-facing, public subnets |
| Compute (instance / tasks) | private subnet, no public IP | private subnet, no public IP |
| DNS | Route 53 private zone alias → internal ALB | public Route 53 alias → ALB |
| Reachable from | inside the VPC (peered / VPN / Direct Connect) | the public internet |
| CodeBuild | VPC-attached (PIPELINE_VPC_ID / SUBNET_IDS / SECURITY_GROUP_IDS) |
AWS-managed network, reaches the ALB over the internet |
| Plugin pull | https://<domain>/v2/ (resolves in-VPC) |
https://<domain>/v2/ (public) |
In private mode, EC2 folds the VPC interface endpoints (S3, Logs, Secrets Manager, KMS, STS, CodeBuild, ECR) and the Route 53 private-zone alias to the internal ALB into its single stack, gated on DeployMode=private (no separate prereqs stack). EKS sets the ALB Ingress to scheme: internal and aliases the domain to it; eksctl provisions the cluster VPC (public + private subnets, NAT). Both request a DNS-validated ACM cert and alias the domain to the ALB (public alias or private zone). For VPC-attached CodeBuild plugin pulls, supply PIPELINE_VPC_ID / SUBNET_IDS and build-dependency egress (NAT / internal mirrors).
Use the matching quickstart below: Public or Private.
A public deployment uses an internet-facing ALB so the dashboard, API, and plugin registry are reachable from the internet over HTTPS (the compute still stays private behind it). See Deployment modes for the full comparison.
--domain + --hosted-zone-id.ghcr.io/mwashburn160/*); they’re public, but GitHub rate-limits anonymous pulls (60/hr) which trips mid-deploy when all 10 images pull at once. Generate your own PAT under your GitHub account: on GitHub go to Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic) (https://github.com/settings/tokens) and check only the read:packages scope. Then pass --ghcr-token <your-pat> (ghcr.io validates only the token for PAT auth — there is no username flag to set; the deploy uses a fixed internal value). Don’t reuse a token from these docs or another deployment. See GhcrToken rejected for the fine-grained-PAT option and details.--key-pair) for break-glass serial-console access (routine access is via SSM).Pick the target. Both take --deploy-mode public; everything else matches the private flow.
# EC2 — single Minikube instance behind an internet-facing ALB
cd deploy/aws/ec2
bash bin/setup.sh --deploy-mode public \
--key-pair my-keypair \
--domain pipeline.example.com \
--hosted-zone-id Z1234567890 \
--ghcr-token ghp_xxxxxxxxxxxx
# EKS — managed Kubernetes (Auto Mode) behind an internet-facing ALB Ingress
cd deploy/aws/eks
bash bin/setup.sh --deploy-mode public \
--domain pipeline.example.com \
--hosted-zone-id Z1234567890 \
--ghcr-token ghp_xxxxxxxxxxxx
The ACM cert DNS-validates during the deploy, so expect setup.sh to wait a few minutes for the cert to reach ISSUED (EC2: while CloudFormation is CREATE_IN_PROGRESS; EKS: aws acm wait certificate-validated). setup.sh runs from your machine with your credentials. The EC2 Deploy section also shows the raw-CloudFormation equivalent.
The URL is simply https://<your-domain> (the value you passed to --domain), reachable once the Route 53 alias resolves and the target(s) pass health checks — a few minutes after the stack completes, while the instance bootstraps / tasks start. To read it back from the stack outputs:
# EC2 — ApplicationURL output is the full https:// URL
aws cloudformation describe-stacks --stack-name pipeline-builder \
--query 'Stacks[0].Outputs[?OutputKey==`ApplicationURL`].OutputValue' --output text
# EKS — the URL is https://<your-domain> (the Route 53 alias setup.sh creates → the ALB Ingress).
# Confirm the ALB hostname the Ingress was assigned:
kubectl get ingress pb-ingress -n pipeline-builder \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}'
Public install is otherwise identical to private — by default the admin user is registered and plugins loaded automatically (EC2 self-inits on first boot; EKS self-inits in setup.sh’s final phase over a kubectl port-forward). To do it yourself, deploy with --init manual and follow Post-Deploy Steps.
Note: “public” exposes only the ALB. The instance/nodes have no public IP and no inbound SSH; admin access is still SSM Session Manager (EC2) or
kubectl(EKS). To make a deployment internal-only later, redeploy with--deploy-mode private(default). See Deployment mode (DEPLOY_MODE) for the full mode comparison.
A private deployment uses an internal-scheme ALB — reachable only from inside your AWS network (the VPC, peered VPCs, or via VPN / Direct Connect), never the public internet. This is the default mode. See Deployment modes for the full comparison.
Identical to the Public quickstart above: AWS CLI, a registered domain + public Route 53 hosted zone, a GitHub PAT (--ghcr-token), and — EC2 only — an EC2 key pair.
The public Route 53 hosted zone is still required even in private mode: ACM validates the cert via a public DNS record. The private hosted zone (for in-VPC resolution of your domain) is created automatically by the stack — you don’t supply it.
private is the default, so --deploy-mode private is optional (shown for clarity). Same flags as public, minus the public exposure.
# EC2 — single Minikube instance behind an internal ALB
cd deploy/aws/ec2
bash bin/setup.sh --deploy-mode private \
--key-pair my-keypair \
--domain pipeline.example.com \
--hosted-zone-id Z1234567890 \
--ghcr-token ghp_xxxxxxxxxxxx
# EKS — managed Kubernetes (Auto Mode) behind an internal ALB Ingress
cd deploy/aws/eks
bash bin/setup.sh --deploy-mode private \
--domain pipeline.example.com \
--hosted-zone-id Z1234567890 \
--ghcr-token ghp_xxxxxxxxxxxx
In private mode, EC2 also folds the VPC interface endpoints (S3, Logs, Secrets Manager, KMS, STS, CodeBuild, ECR) and the Route 53 private-zone alias to the internal ALB into its single stack (gated on DeployMode=private, no separate prereqs stack). EKS sets the ALB Ingress to scheme: internal and aliases the domain to it. Either way the ACM cert still DNS-validates during the deploy, so expect a few minutes of waiting for it to issue.
The URL is the same https://<your-domain>, but it resolves only from inside the VPC (via the private hosted zone) — it will not resolve from your laptop or the public internet. For EC2, read it back from the stack output (ApplicationURL); for EKS the URL is https://<your-domain> from the Route 53 alias, and you can confirm the ALB hostname as in the public step 2.
By default this happens automatically. EC2 runs init-platform.sh ec2 on first boot (as the minikube user, in-VPC); EKS runs init-platform.sh eks from setup.sh’s final phase over a kubectl port-forward to svc/nginx. Both register the admin (default password) and load plugins/compliance/samples — watch EC2 with sudo tail -f /var/log/user-data.log (after SSM).
If you deployed with --init manual (or want to re-run / set real admin creds):
aws ssm start-session --target <instance-id>), sudo -iu minikube, cd /opt/pipeline/pipeline-builder, then run ./deploy/bin/init-platform.sh ec2; it’s already in-VPC../deploy/bin/init-platform.sh eks with kubectl access to the cluster. It port-forwards svc/nginx (8080), so it works without the domain resolving — no VPC-attached host required.Then load plugins per Post-Deploy Steps.
Note: private mode also wires CodeBuild into the VPC (
PIPELINE_VPC_ID/SUBNET_IDSfrom the foundation VPC) so it can reach the internal ALB and pull plugin images overhttps://<domain>/v2/. You still supply egress (NAT / package mirrors) for build dependencies. See Deployment mode (DEPLOY_MODE) for the full comparison.
Single hardened EC2 instance running Minikube with all services.
public and private mode)For the one-command happy path, use the Public or Private quickstart — both run bin/setup.sh from your machine with your credentials (so the instance role needs no CloudFormation permissions). An ALB fronts the always-private instance and terminates TLS with an ACM cert the template DNS-validates against your zone, so --domain + --hosted-zone-id are required; setup.sh refuses to start without them.
Manual alternative (raw CloudFormation). Deploys the same single stack — nothing to follow up with. The ACM cert DNS-validates during stack creation, so expect a few minutes in CREATE_IN_PROGRESS:
cd deploy/aws/ec2
aws cloudformation deploy \
--stack-name pipeline-builder \
--template-file template.yaml \
--parameter-overrides \
DeployMode=private \
DomainName=pipeline.example.com \
HostedZoneId=Z1234567890 \
KeyPairName=my-keypair \
GhcrToken=ghp_xxxxxxxxxxxx \
--capabilities CAPABILITY_IAM
# (DeployMode=public for an internet-facing ALB; domain + zone still required.)
Get the URL:
aws cloudformation describe-stacks --stack-name pipeline-builder \
--query 'Stacks[0].Outputs[?OutputKey==`ApplicationURL`].OutputValue' --output text
| Parameter | Required | Default | Description |
|---|---|---|---|
KeyPairName |
Yes | — | EC2 key pair (serial-console/break-glass; routine access is via SSM) |
GhcrToken |
Yes | — | GHCR token for pulling images |
DomainName |
Yes | — | FQDN — ACM cert + Route 53 alias to the ALB |
HostedZoneId |
Yes | — | Public Route 53 zone ID (ACM DNS validation + alias) |
InstanceType |
No | m5.4xlarge |
EC2 instance type (16 vCPU / 64 GiB) — the smallest size on which every HPA can reach maxReplicas alongside the mesh and the self-hosted ask-model. t3.2xlarge (8 vCPU / 32 GiB) runs the stack at steady state with less headroom; use t3.xlarge only with Lean=true. |
Lean |
No | false |
When true, omit the optional observability/admin services + single-replica everything so the core stack + mesh fits a t3.xlarge. Pair with InstanceType=t3.xlarge. See Service Mesh: LEAN mode. |
EbsVolumeSize |
No | 60 |
Root volume size in GiB (OS, binaries) |
DataVolumeSize |
No | 500 |
Data volume size in GiB (/opt/pipeline, gp3 encrypted) — Docker, plugins, registry, databases. Minimum 440, which is 2x what the stack commits to this volume: 140 GiB of declared PersistentVolume claims (7 services x 20 GiB) plus the 80 GiB minikube node disk, which lives here because Docker’s data-root does. |
GitRepo |
No | (this repo) | Git repository URL |
GitBranch |
No | main |
Branch to deploy |
The EC2 deployment uses two EBS volumes:
| Volume | Default | Mount | Contents |
|---|---|---|---|
| Root | 60 GiB | / |
OS, Docker/minikube binaries, app code |
| Data | 500 GiB | /opt/pipeline |
Docker layers, plugin artifacts, registry, databases, logs |
Data volume breakdown:
| Component | build_image | prebuilt | prebuilt + –cleanup |
|---|---|---|---|
| Docker build cache + images | 20-30 GB | 60-90 GB | 60-90 GB |
| Plugin artifacts (image.tar + plugin.zip) | 0 GB | 130-190 GB | 0 GB |
| Registry (pushed images) | 40-60 GB | 40-60 GB | 40-60 GB |
| PostgreSQL + MongoDB | 5-15 GB | 5-15 GB | 5-15 GB |
| Minikube + logs + metrics | 15-25 GB | 15-25 GB | 15-25 GB |
| Total | 80-130 GB | 250-380 GB | 120-190 GB |
The figures above are OBSERVED usage. The volume is sized from what the stack
commits instead, at a 2x minimum — 140 GiB of declared PV claims (every
hostPath in k8s/ is under /opt/pipeline/pipeline-data, each claiming
20 GiB) plus the 80 GiB minikube node disk, so the floor is 440 GiB. Postgres
and MongoDB using 5-15 GB today does not lower it: the claims are what a CSI
would enforce if this target ever stopped using hostPaths, and the node disk is
a real file on this volume either way.
Recommendations:
| Plugin strategy | Data volume | Notes |
|---|---|---|
build_image (default) |
440 GB | The floor. Builds from Dockerfile at upload time |
prebuilt with --cleanup |
500 GB | Removes artifacts after upload |
prebuilt without cleanup |
700 GB | Keeps 130-190 GB of artifacts for re-runs |
Daily runtime operations (after initial plugin load) add ~1-5 GB/month from database growth and logs. Add a weekly Docker prune cron to reclaim build cache:
# /etc/cron.weekly/docker-prune
docker system prune -af --filter "until=168h"
If you need more storage after deployment (e.g., switching to prebuilt), expand the data volume live — no reboot required:
# 1. Find the data volume ID
INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $(curl -s -X PUT \
http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 21600')" \
http://169.254.169.254/latest/meta-data/instance-id)
VOL_ID=$(aws ec2 describe-volumes \
--filters "Name=attachment.instance-id,Values=$INSTANCE_ID" "Name=tag:Name,Values=*data*" \
--query 'Volumes[0].VolumeId' --output text)
# 2. Expand to desired size (e.g., 500 GiB for prebuilt)
aws ec2 modify-volume --volume-id $VOL_ID --size 500
# 3. Wait for modification to complete (~30s)
watch -n5 "aws ec2 describe-volumes-modifications --volume-ids $VOL_ID \
--query 'VolumesModifications[0].ModificationState' --output text"
# Wait until it shows "optimizing" or "completed"
# 4. Grow the partition and filesystem (on the EC2 instance)
DEVICE=$(lsblk -no PKNAME $(findmnt -n -o SOURCE /opt/pipeline))
PART=$(lsblk -no PARTNUM $(findmnt -n -o SOURCE /opt/pipeline) 2>/dev/null)
[ -n "$PART" ] && sudo growpart /dev/$DEVICE $PART
sudo xfs_growfs /opt/pipeline # XFS filesystem
# or: sudo resize2fs $(findmnt -n -o SOURCE /opt/pipeline) # ext4 filesystem
# 5. Verify
df -h /opt/pipeline
Deploy with a larger data volume upfront:
aws cloudformation deploy \
--stack-name pipeline-builder \
--template-file template.yaml \
--parameter-overrides \
DataVolumeSize=500 \
KeyPairName=my-key \
GhcrToken=ghp_xxx \
--capabilities CAPABILITY_IAM
bootstrap.sh, which:
.env with random secrets (JWT keys, DB passwords)startup.sh — creates Minikube (fresh, no cluster yet), installs the mesh/KEDA, deploys all K8s manifests:30080 → Minikube NodePort 30080 (the ALB target). TLS is terminated at the ALB (ACM) — no cert on the box.First boot always CREATES a fresh cluster —
startup.shfinds no existing profile, so it never prompts (theRECREATEprompt is TTY-gated and only reached on an existing cluster; a non-interactive UserData run has no TTY). If you later SSH onto the box and re-runstartup.shagainst a running/stopped cluster, it resumes by default; force a cluster rebuild withRECREATE=y sudo -u minikube deploy/aws/ec2/bin/startup.sh. The EC2 data lives on the host ($DATA_DIR), so a rebuild re-mounts the same data — to truly wipe, clear$DATA_DIRon the host first.
The instance is private (no public IP); use SSM:
# Watch bootstrap progress
aws ssm start-session --target <instance-id> # then: sudo tail -f /var/log/user-data.log
# Check pods
aws ssm start-session --target <instance-id> # then: sudo -u minikube kubectl get pods -n pipeline-builder
The ALB target reports unhealthy (503) until the instance finishes bootstrapping Minikube + services — expected; it self-heals.
All in deploy/aws/ec2/bin/. On the instance the repo is checked out under the
data volume, so the scripts live at /opt/pipeline/pipeline-builder/deploy/aws/ec2/bin/.
| Script | Purpose | Run as |
|---|---|---|
setup.sh |
Deploy the stack (private mode folds endpoints + private zone into it) — from your machine | operator |
bootstrap.sh |
Full EC2 setup (runs automatically via UserData) | root |
startup.sh |
Start Minikube + deploy K8s manifests + the ALB-target iptables bridge | root (sudo) |
shutdown.sh |
Stop Minikube + remove iptables rules | root (sudo) |
The instance has no public IP / no SSH — connect with SSM Session Manager first:
aws ssm start-session --target <instance-id> # then, on the instance:
# Start (after bootstrap or reboot)
sudo bash /opt/pipeline/pipeline-builder/deploy/aws/ec2/bin/startup.sh
# Stop
sudo bash /opt/pipeline/pipeline-builder/deploy/aws/ec2/bin/shutdown.sh
# Check pod status
sudo -u minikube kubectl get pods -n pipeline-builder
30080 — no 0.0.0.0/0, no public SSH (port 22 closed; access via SSM Session Manager)80→443 redirect at the ALBTLS is terminated at the ALB with an ACM certificate the template requests and DNS-validates against HostedZoneId. ACM auto-renews it; there is no certbot/Let’s Encrypt and no cert on the instance. nginx serves plain HTTP behind the ALB.
DEPLOY_MODE)See Deployment modes for the public/private comparison and what each changes. DEPLOY_MODE defaults to private; pass --deploy-mode public (or DEPLOY_MODE=public) for the internet-facing posture. The instance is always private and TLS is always ACM-at-the-ALB regardless of mode; the private-mode VPC endpoints + private-zone alias are folded into the single stack (gated on DeployMode=private) — no separate prereqs stack.
DEPLOY_MODE and the VPC identity (PIPELINE_VPC_ID / PIPELINE_SUBNET_IDS) are injected into the instance .env automatically by bootstrap.sh (exported from the template’s UserData, from the stack’s VPC + private subnets) — and passed through to the first-boot init — so the synthesized CodeBuild attaches to the VPC and init-platform.sh’s private-mode preflight passes with no manual step. (If you run init-platform.sh by hand on the box, the values are already in .env.)
aws cloudformation delete-stack --stack-name pipeline-builder
aws cloudformation wait stack-delete-complete --stack-name pipeline-builder
Managed Kubernetes on Amazon EKS Auto Mode — AWS-managed, Karpenter-scaled EC2 nodes with the AWS Load Balancer Controller, EBS CSI, and CoreDNS built in (EFS CSI added by the deploy). One orchestrator script stands up the cluster and applies the same Kubernetes workloads as the minikube/ec2 targets, tuned for multi-node (PVC storage, ALB Ingress).
Why EKS Auto Mode? Plugin/base images are built with rootless BuildKit, which needs an unconfined seccomp profile to create its user namespace — only possible on EC2-backed Kubernetes nodes (
securityContext.seccompProfile: Unconfined). Auto Mode keeps node management hands-off while running on EC2, so BuildKit works and the proven k8s manifests are reused as-is.
eksctl (creates/destroys the Auto Mode cluster) — auto-installed by setup.sh/shutdown.sh (latest binary) when it isn’t already on PATH.kubectl (applies the manifests), openssl (registry token keypair), and envsubst (renders cluster.yaml + the manifests). infra provision checks all of these; deploy/bin/provision-docker.sh --target eks installs them in a throwaway container if you’d rather not put them on your host.Use the Public or Private quickstart for the one-command path — bin/setup.sh runs all phases end to end. It requests a DNS-validated ACM cert for --domain and terminates TLS at the ALB Ingress (no certbot, no self-signed cert), so --domain + --hosted-zone-id are required in both modes. The cert validates mid-deploy (aws acm wait certificate-validated), so expect a few minutes of waiting there.
| Parameter | Required | Default | Description |
|---|---|---|---|
--domain |
Yes | — | FQDN — ACM cert + Route 53 alias to the ALB Ingress |
--hosted-zone-id |
Yes | — | Public Route 53 zone ID (ACM DNS validation + alias) |
--ghcr-token |
Yes | — | GHCR token for pulling the service images |
--deploy-mode |
No | private |
public (internet-facing ALB) or private (internal) |
--cluster-name |
No | pipeline-builder |
EKS cluster name (set a second one to run multiple environments) |
--no-email |
No | — | Skip SES (transactional email is provisioned by default) |
--email-from |
No | noreply@<domain> |
From address SES sends as |
--email-from-name |
No | pipeline-builder |
Display name on outbound email |
--no-create-ses-identity |
No | — | Skip creating the SES identity (domain already verified in this account) |
--alert-email |
No | — | Subscribe an address to the SES bounce/complaint SNS topic |
--region |
No | us-east-1 |
AWS region |
DEPLOY_MODE)See Deployment modes for the public/private comparison. DEPLOY_MODE defaults to private; set it in the env before setup.sh or use --deploy-mode public. On EKS it controls only the ALB Ingress scheme — internal (private) vs internet-facing (public) — and the Route 53 record. For VPC-attached CodeBuild plugin pulls in private mode, supply PIPELINE_VPC_ID / SUBNET_IDS (the eksctl cluster VPC).
bin/setup.sh runs these in order — there are no per-component CloudFormation stacks (eksctl manages the cluster’s own stacks under the hood):
| Phase | Contents |
|---|---|
| 1. Cluster | eksctl creates the EKS Auto Mode cluster (cluster/cluster.yaml) + the aws-efs-csi-driver addon |
| 2. EFS | Encrypted EFS filesystem + security group (NFS from the nodes) + mount targets in the private subnets → the pb-efs (RWX) StorageClass |
| 3. ACM | DNS-validated ACM cert for --domain (publishes the validation record to Route 53, waits for ISSUED) |
| 4. Secrets | Namespace + the secret/ConfigMap set the manifests expect (JWT, DB creds, registry token keypair, app-env, DB init, observability configs) — same layout as the ec2 target |
| 5. Pod Identity | SES ses:SendEmail/ses:SendRawEmail association for the platform ServiceAccount (when email is enabled) |
| 6. KEDA | Installs the KEDA operator (the plugin ScaledObject autoscaler — Auto Mode doesn’t bundle it) |
| 7. Workloads | kubectl kustomize k8s | kubectl apply — all services: Nginx, Platform, Pipeline, Plugin, Quota, Billing, Message, Reporting, Compliance, Frontend, the in-cluster image-registry, observability (Prometheus, Loki, Alertmanager — surfaced via /dashboard/observability), admin tools (PgAdmin, Mongo Express), and the ALB Ingress |
| 8. Route 53 | A-alias --domain → the ALB the Ingress provisions |
setup.sh applies the manifests, then the pods need a minute or two to pull, start, and pass readiness. The ALB target group reports unhealthy (503) until nginx and the platform are ready — expected; it self-heals.
# Watch the rollout reach a steady state
kubectl get pods -n pipeline-builder -w
# Wait on the gateway + core services
kubectl rollout status deploy/nginx deploy/platform deploy/pipeline deploy/plugin -n pipeline-builder
# Shell into a running pod (platform)
kubectl exec -it deploy/platform -n pipeline-builder -- /bin/sh
Plugin base images are seeded by init-platform.sh eks (the post-deploy step) — built by the in-cluster rootless buildkitd and pushed to the in-cluster registry; setup.sh itself doesn’t build them. By default infra provision runs that init for you over a kubectl port-forward; with the raw script, run ./deploy/bin/init-platform.sh eks once the registry is up (see Post-Deploy Steps). It works from anywhere with kubectl access — no VPC-attached host needed, even in private mode.
Persistent state lives on PVCs provisioned by the EBS/EFS CSI drivers — no EBS volumes to hand-manage. PostgreSQL, MongoDB, Redis, and the rest run as Kubernetes workloads (the postgres/mongo/redis images), not as RDS/DocumentDB/ElastiCache. Plugin images are built by an in-cluster rootless BuildKit sidecar and pushed to the in-cluster registry (there is no ECR dependency).
| Resource | Storage class | Size | Notes |
|---|---|---|---|
| PostgreSQL | pb-ebs (RWO) | 5-15 GB | Pipelines, plugins, compliance, messages |
| MongoDB | pb-ebs (RWO) | 10-20 GB | Quota + billing records |
| Prometheus / Alertmanager / PgAdmin | pb-ebs (RWO) | 1-10 GB each | Metrics, alert state, admin UI |
| In-cluster registry | none — RustFS | — | Stateless: images go to the registry bucket via the S3 storage driver |
| Loki | none — RustFS | — | Chunks + index ship to the loki bucket |
| Redis | pb-ebs (RWO) | 1-5 GB | Sentinel HA StatefulSet (3 Redis + 3 Sentinel) — queues + cache |
| Plugin builds / uploads | pb-efs (RWX) | per-pod | BuildKit layer cache + upload staging (shared in-pod with the sidecar) |
Recommendations:
| Resource | Setting |
|---|---|
| pb-ebs PVCs | gp3, ReclaimPolicy: Retain — data survives a PVC/pod delete (clean up orphans manually) |
| pb-efs | Elastic — grows automatically; no pre-provisioning |
| Registry growth | Prune old plugin image tags from the in-cluster registry periodically |
Monthly cost — from the AWS Pricing API (us-east-1, 2026-10-09), at the template’s own defaults.
The default InstanceType is m5.4xlarge, the TOP of the range this doc
used to quote as “$140-265/mo” — so a deploy that changes nothing pays the
high end, not the low one. Storage was omitted entirely: EbsVolumeSize=60 +
DataVolumeSize=500 is 560 GiB of gp3, about $45/mo on its own.
| Shape | Compute | 560 GiB gp3 | Total 24/7 |
|---|---|---|---|
| Default — m5.4xlarge | ~$561 | ~$45 | ~$610-680/mo |
| t3.2xlarge | ~$243 | ~$45 | ~$290-360/mo |
| t3.xlarge (LEAN, smaller volumes) | ~$121 | ~$20 | ~$145-215/mo |
Ranges include the ALB, EIP and data transfer, which vary with use. As with EKS, stopping the instance when idle beats every other saving — this target is a single instance, so stopping it costs only the EBS.
Monthly cost — MEASURED, not estimated.
Taken from AWS Cost Explorer on 2026-10-09 for the cluster as it actually runs (6 nodes: 4x c6a.xlarge + 2x m5a.xlarge, ~34 workloads). The figures that used to sit here were a bottom-up estimate totalling ~$150-400/mo, and they were low by roughly 2-3x — the node line in particular assumed far fewer nodes than Karpenter actually schedules for this workload.
| Resource | Cost |
|---|---|
| EC2 compute (Karpenter, on-demand) | ~$560-640 |
| EC2 - Other (EBS, NAT, data transfer) | ~$190-240 |
| EKS control plane + Auto Mode | ~$73-80 |
| ALB | ~$10-30 |
| VPC, WAF, Secrets Manager, CloudWatch | ~$15-25 |
| Total, running 24/7 | ~$850-950/mo |
Two things move this number far more than any tuning:
deploy/aws/eks/bin/shutdown.sh).
For a dev or demo cluster this dwarfs every other saving available.The GPU node for Ask is the single largest line if enabled. The pool
prefers g4dn.xlarge at ~$0.53/hr — ~$384/mo on its own at 24/7, with
g5.xlarge (~$1.00/hr, ~$734/mo) as the fallback when g4dn capacity is short.
It is not included above; see deploy/aws/eks/k8s/ask-model.yaml and the
ask-model-gpu NodePool in cluster/nodepool.yaml for how it is enabled.
Most of that is recoverable without giving the feature up. ask-model is
scheduled to business hours by default on this target
(k8s/ask-model-schedule.yaml): two CronJobs scale it to 0 outside the
window, which is what lets Karpenter deprovision the node, taking the line to
roughly $106/mo for the default Mon-Fri 07:45-17:00 UTC window (201
h/month against 730); every day instead of weekdays is ~$148/mo
(ASK_SCHEDULE_UP / ASK_SCHEDULE_DOWN / ASK_SCHEDULE_ENABLED in .env —
see Environment Variables). Outside the window
Ask’s model calls fail rather than queue — answered as a 503 naming the
window, not a raw connection error — so set ASK_SCHEDULE_ENABLED=false if
out-of-hours use matters.
Node cost is the dominant, workload-dependent term: Karpenter scales nodes to
fit scheduled pods, and the NodePool ceiling in cluster/nodepool.yaml
(48 vCPU / 96Gi) is what bounds the worst case rather than the typical one.
pb-ebs PVCs (postgres / mongodb / prometheus / …): the gp3 StorageClass allows volume expansion, so grow a volume by raising the PVC request and letting the EBS CSI driver expand it online:
kubectl patch pvc postgres-data -n pipeline-builder \
-p '{"spec":{"resources":{"requests":{"storage":"30Gi"}}}}'
# the EBS CSI driver expands the volume + filesystem online (gp3) — no pod restart needed
pb-efs (plugin build uploads) — no expansion needed: EFS is elastic and grows automatically. (The registry, Loki, and message attachments now live in RustFS (this target’s S3-compatible object store), not EFS — the registry is stateless S3, Loki ships chunks/index to S3; only plugin build uploads still use pb-efs.) Cap object-store growth via bucket lifecycle rules + Loki retention_period; check EFS usage via aws efs describe-file-systems.
Cluster capacity: node capacity is managed by Karpenter (Auto Mode) — it provisions and removes EC2 nodes to fit scheduled pods, so there is no instance to resize.
EKS reuses the same Kubernetes manifests as minikube/ec2, with these AWS-managed substitutions:
| minikube / ec2 | EKS |
|---|---|
| hostPath volumes | EBS (RWO) + EFS (RWX) PVCs via CSI |
| NodePort + iptables bridge | ALB Ingress (target-type: ip → nginx:8080) |
| Single node | Karpenter-scaled EC2 nodes (Auto Mode) |
| EC2 instance role (SES) | EKS Pod Identity association |
| Self-managed addons | Auto Mode: AWS LB Controller, EBS CSI, CoreDNS built in (EFS CSI added) |
All in deploy/aws/eks/bin/. Run from your local machine (or via infra provision).
| Script | Purpose |
|---|---|
setup.sh |
Full deploy: cluster → EFS → ACM → secrets → KEDA → manifests → Route 53 |
shutdown.sh |
Teardown: Ingress/ALB → Route 53 → EFS → cluster → ACM cert |
# Pod / service status
kubectl get pods,svc -n pipeline-builder
# Tail logs for a service
kubectl logs -f deploy/nginx -n pipeline-builder
The ACM cert is DNS-validated and auto-renews — nothing to do (ACM rotates it as long as the validation CNAME stays in the hosted zone). The ALB Ingress picks up the renewed cert automatically.
cd deploy/aws/eks
bash bin/shutdown.sh --cluster-name pipeline-builder --region us-east-1 \
--domain pipeline.example.com --hosted-zone-id Z123 --yes
Deletes the Ingress/ALB, the Route 53 alias, the EFS filesystem, the cluster (eksctl delete cluster), and the ACM cert — in dependency order.
EBS volumes on the
pb-ebs(Retain) StorageClass are not auto-deleted (they’re reported at the end). Remove leftovers manually if you don’t need the data.
The platform sends transactional email (invitations, email verification, password
resets) via Amazon SES. It’s enabled by default — every AWS deploy provisions
it; pass --no-email to skip it:
# EC2 — SES is provisioned by default
bash bin/setup.sh --key-pair my-keypair --domain pipeline.example.com \
--hosted-zone-id Z123 --ghcr-token ghp_xxx
# EKS — pass --no-email to opt out
bash bin/setup.sh --domain pipeline.example.com \
--hosted-zone-id Z123 --ghcr-token ghp_xxx --no-email
By default the deploy wires up everything in one shot:
--domain and
publishes its 3 DKIM CNAMEs to your Route 53 zone, so the domain
self-verifies — no manual click. (EC2: in template.yaml; EKS: setup.sh
Phase 5 via aws sesv2.) The CNAMEs always go to the public hosted zone,
so this works in private mode too. Pass --no-create-ses-identity if the
domain is already a verified SES identity in this account.ses:SendEmail and ses:SendRawEmail on identity/*
and the configuration set, with a ses:FromAddress condition equal to the
From address — that condition is what scopes it (not AmazonSESFullAccess).
On EC2 it goes to the instance role (the platform pod reaches it over
IMDS; metadata hop limit is already 2); on EKS to an EKS Pod Identity
association for the platform ServiceAccount. No access keys are created or
stored. Each part is required: the platform sends through nodemailer’s SES
transport, which SES authorizes as SendRawEmail; SES checks every identity on
a message, and in the sandbox that includes each recipient’s; and every
send routes through the configuration set.EMAIL_ENABLED=true, EMAIL_PROVIDER=ses,
SES_REGION=<deploy region>, EMAIL_FROM=noreply@<domain>,
EMAIL_FROM_NAME=pipeline-builder, SES_CONFIGURATION_SET. Override the
sender with --email-from / --email-from-name. On EKS a re-deploy also
switches an existing .env that still holds the template’s SMTP
placeholders (EMAIL_PROVIDER=smtp, SMTP_HOST=localhost) to SES; a real SMTP
relay is left alone.| Flag | Default | Purpose |
|---|---|---|
--no-email |
— | Skip SES (it is provisioned by default: identity + DKIM + role grant + app env) |
--email-from |
noreply@<domain> |
From address SES sends as |
--email-from-name |
pipeline-builder |
Display name on outbound email |
--no-create-ses-identity |
— | Skip identity creation when --domain is already a verified SES identity in this account/region (avoids a “already exists” rollback); IAM + env are still wired |
--alert-email |
— | Subscribe this address to the bounce/complaint SNS topic (you must confirm the email AWS sends) |
Region matters: the SES identity is regional and must match the deploy region. The deploy pins
SES_REGIONto it automatically (EC2 derives it from the stack region inbootstrap.sh, not the static.envdefault).
Two things happen after the stack completes, and both need your attention:
admin@internal (it bounces, and sandbox bounces hurt the reputation
AWS reviews for production approval).SES enforces sender reputation at the account level — above ~5% bounce or
~0.1% complaint it puts the account under review and can pause all sending
(including password resets). To make that visible instead of a silent outage,
the deploy provisions a configuration set that every send routes through
(SES_CONFIGURATION_SET on the platform), with an SNS topic receiving every
bounce, complaint, and reject (pipeline-builder-email-events on EC2;
<cluster-name>-email-events on EKS).
Pass --alert-email you@example.com to subscribe an address at deploy time
(confirm the subscription email AWS sends), or subscribe the topic later from the
console. Without a subscription the topic still collects events — you just won’t
be alerted. Reputation rates are also on the SES console Account dashboard.
The deploy leaves SES verified but sandboxed. These steps finish the job; do 1–6
before requesting production access, because AWS weighs bounce handling and
domain authentication when it reviews the request. REGION, TOPIC_ARN and the
addresses are yours to substitute.
aws sesv2 put-email-identity-mail-from-attributes --email-identity <domain> \
--mail-from-domain mail.<domain> --behavior-on-mx-failure USE_DEFAULT_VALUE --region REGION
then publish mail.<domain> MX 10 feedback-smtp.REGION.amazonses.com and
mail.<domain> TXT "v=spf1 include:amazonses.com ~all".
_dmarc.<domain> TXT "v=DMARC1; p=none; rua=mailto:<you>@<domain>".
Easy DKIM already aligns, so this passes from day one; tighten to
p=quarantine after a few weeks of clean reports.aws sesv2 create-email-identity --email-identity <you>@<domain> --region REGION.
To resend the link for a pending address, use the v1 API:
aws ses verify-email-identity --email-address <you>@<domain>.aws sns subscribe --topic-arn TOPIC_ARN --protocol email \
--notification-endpoint <you>@<domain> --region REGION
Click the confirmation link. Re-running subscribe for an address that is
still pending does not reliably resend it, and a pending subscription
cannot be deleted (it expires after 3 days). Use Request confirmation on
the subscription in the SNS console, or subscribe a plus-address
(<you>+alerts@<domain>) if your mail host supports sub-addressing.
aws sesv2 put-account-details --production-access-enabled --mail-type TRANSACTIONAL …).
The first answer is often an automated “needs more information” asking for:
website URL, email type, expected volume, how recipients opt in, bounce and
complaint handling, and a sample email. Reply on the case in Support
Center — replying to the notification email bounces, because it comes from
a no-reply address — and do not submit a second request. AWS uses the volume
you give to set your initial sending quota, so give a realistic transactional
estimate; you can request an increase later.Superadmin email. If you replace
admin@internalinBOOTSTRAP_SUPERADMIN_EMAILSwith a real address so notifications reach a person, add it rather than replacing (fresh installs needadmin@internal), and register that account yourself first: any account that registers with a listed email is promoted to superadmin on registration.
After deploying (EC2 or EKS), complete these steps to initialize the platform and enable reporting.
Register the admin user and load pre-built plugins and sample pipeline templates:
cd deploy
# Prompts for build strategy + categories. Admin creds come from PLATFORM_IDENTIFIER /
# PLATFORM_PASSWORD (defaulting if unset — export real values on ec2/eks)
bash bin/init-platform.sh ec2 # EC2 (resolves URL from the pipeline-builder stack)
bash bin/init-platform.sh eks # EKS (port-forwards svc/nginx via kubectl)
bash bin/init-platform.sh docker # Docker Compose
bash bin/init-platform.sh minikube # Minikube
# Non-interactive
export PLATFORM_BASE_URL=https://pipeline.example.com
export PLATFORM_IDENTIFIER=admin@internal
export PLATFORM_PASSWORD='<a strong secret you generate>'
bash bin/init-platform.sh ec2
# Non-interactive with prebuilt images
PLUGIN_BUILD_STRATEGY=prebuilt bash bin/init-platform.sh ec2
# Non-interactive with prebuilt + specific categories
PLUGIN_BUILD_STRATEGY=prebuilt PLUGIN_CATEGORY=infrastructure,language bash bin/init-platform.sh ec2
# Control upload parallelism (default: 4, auto-lowered to 1 for prebuilt)
PARALLEL_JOBS=2 bash bin/init-platform.sh docker
# Force rebuild all prebuilt images even if image.tar exists
PLUGIN_BUILD_STRATEGY=prebuilt FORCE_REBUILD=true bash bin/init-platform.sh ec2
# Force a from-scratch rebuild of the base images AND the CodeBuild bootstrap
# image, bypassing the docker-cache / registry-tag skips (equivalent to
# FORCE_REBUILD=true plus --force/FORCE_PUSH on the bootstrap build)
bash bin/init-platform.sh --force ec2
# Clean up plugin.zip and image.tar after upload (reclaim disk space)
./deploy/bin/init-platform.sh --cleanup docker
./deploy/bin/load-plugins.sh --rebuild --cleanup
# EC2 with sudo (required for minikube user context)
sudo -u minikube PLATFORM_BASE_URL=https://your-ip bash /opt/pipeline/pipeline-builder/deploy/bin/init-platform.sh ec2
sudo -u minikube PLATFORM_BASE_URL=https://your-ip bash /opt/pipeline/pipeline-builder/deploy/bin/init-platform.sh --cleanup ec2
init-platform.sh does: health check → register admin → login → select build strategy → load plugins → load pipelines.
| Variable | Default | Description |
|---|---|---|
PLATFORM_BASE_URL |
auto-detected | Platform API URL (skips CloudFormation lookup when set) |
PLATFORM_IDENTIFIER |
admin@internal |
Admin email |
PLATFORM_PASSWORD |
Pipeline-Builder-Dev-2026! (local targets only) |
Admin password. The dev default is REFUSED on ec2/eks — set a strong secret there. |
PLUGIN_BUILD_STRATEGY |
build_image |
build_image or prebuilt |
PLUGIN_CATEGORY |
all | Comma-separated categories (e.g., language,security) |
PARALLEL_JOBS |
4 (1 for prebuilt) | Upload concurrency. Passed through to load-plugins.sh. Override with --parallel N on CLI. |
FORCE_REBUILD |
false |
Force rebuild all prebuilt image.tar files |
PLUGIN_S3_CLEAR |
false |
Clear S3 bucket before upload (S3 strategy only) |
Use --cleanup flag on init-platform.sh or load-plugins.sh to remove plugin.zip and image.tar files after upload. Useful on EC2 where prebuilt images can consume 25-75GB of disk.
Use --force on init-platform.sh to rebuild the base images and the CodeBuild bootstrap image from scratch, ignoring the docker-cache / registry-tag idempotency skips (drives FORCE_REBUILD for the base images and --force + FORCE_PUSH for the bootstrap image). Use it after changing a base Dockerfile or the bootstrap image contents, when the cached tag would otherwise be reused.
| Script | Purpose |
|---|---|
init-platform.sh |
Register admin + select build strategy + load plugins + pipelines (interactive) |
build-plugin-images.sh |
Pre-build Docker images for plugins (prebuilt strategy) |
load-plugins.sh |
Upload plugins from deploy/plugins/ |
load-templates.sh |
Upload pipeline templates from deploy/samples/templates/ |
test-plugins.sh |
Validate plugin specs and Dockerfiles |
build-codebuild-bootstrap.sh |
Build + publish the CodeBuild bootstrap image (CODEBUILD_DEFAULT_IMAGE fallback runtime) |
build-codebuild-bootstrap.sh)The bootstrap image backs CODEBUILD_DEFAULT_IMAGE — the fallback runtime CodeBuild uses for the synth step and for any plugin step whose own image isn’t resolved. build-codebuild-bootstrap.sh builds it with Docker and publishes it to the platform’s registry. provision --build-bootstrap runs this for you; run it directly to refresh the image after a CLI/Dockerfile change.
The bootstrap image bakes in aws-cdk + esbuild + pnpm so synth bundles the PluginLookup Lambda locally (no Docker-in-Docker). If you instead run
cdk synth/pipeline deployon your own machine, you need those same tools onPATH— otherwise bundling fails withCould not resolve "axios". See Pipeline Manager → local deploy prerequisites.
cd deploy/bin
# Build (if missing) + publish to the EC2 platform registry
DEPLOY_TARGET=ec2 ./build-codebuild-bootstrap.sh
# Force a rebuild even if the image is cached, and republish over an existing tag
DEPLOY_TARGET=ec2 FORCE_PUSH=true ./build-codebuild-bootstrap.sh --force
# Pin the pipeline-manager version baked into the image (avoids the @latest
# layer-cache footgun that can ship stale CLI code)
DEPLOY_TARGET=ec2 PIPELINE_MANAGER_VERSION=3.4.131 ./build-codebuild-bootstrap.sh --force
# Cut a NEW image version instead of refreshing 1.0 in place
DEPLOY_TARGET=ec2 BOOTSTRAP_IMAGE_TAG=pipeline-bootstrap:1.1 ./build-codebuild-bootstrap.sh
Environment variables
| Variable | Default | Description |
|---|---|---|
DEPLOY_TARGET |
docker |
Push transport: docker | minikube | ec2 | eks. ec2/minikube push via a kubectl run crane pod. |
FORCE_PUSH |
false |
Republish even when the remote tag already exists. |
BOOTSTRAP_IMAGE_TAG |
pipeline-bootstrap:1.0 |
Image tag to build/push. Must match the deployment’s CODEBUILD_DEFAULT_IMAGE — a mismatch fails CodeBuild with BUILD_CONTAINER_UNABLE_TO_PULL_IMAGE. |
PIPELINE_MANAGER_VERSION |
latest |
npm dist-tag/version baked in; auto-resolved to a concrete version so the Docker layer cache is correct. |
PUBLISH_PLATFORM |
linux/amd64 |
Build/push platform; set linux/arm64 for an all-Graviton stack. |
--force rebuilds the local image; without it an already-cached image skips straight to publish.
Run it on the EC2 instance — the ec2 transport runs a kubectl run crane pod, so it needs both Docker and a kubeconfig that reaches the cluster. Two gotchas:
sudo. sudo runs as root, whose $HOME has no kubeconfig, so the publish fails with “the deploy-bootstrap service key was not found … Available contexts: (none)”. Instead grant your user Docker access and run without sudo:
sudo usermod -aG docker "$(whoami)" && newgrp docker
DEPLOY_TARGET=ec2 ./build-codebuild-bootstrap.sh
If you must use sudo, hand root the kubeconfig + context explicitly:
sudo KUBECONFIG=/home/minikube/.kube/config KUBECTL_CONTEXT=pipeline-builder \
DEPLOY_TARGET=ec2 ./build-codebuild-bootstrap.sh
(Find the context name with kubectl config get-contexts; default is pipeline-builder.)
infra store-token before publishing — otherwise the crane push / CodeBuild image pull can 401.--scope registry:push (what CodeBuild presents to the registry) and --scope reporting:ingest (what the event Lambda reads). Each is a separate service account with only the authority its job needs; see Authentication → Stored machine credentials (AWS). Each secret holds an opaque pb_sa_… key in its password field — the full procedure, including the ordering that matters, is in Access Keys and Machine Credentials.The Lambdas and CodeBuild read service-account keys from Secrets Manager —
machine identities owned by the org, not a person’s token. infra store-token
provisions the account and issues the key:
# First sign in (browser device flow — prints a code, no password on the CLI).
# The session is stored per-platform, so store-token picks it up automatically.
pipeline-manager auth login --no-verify-ssl
# Issuing a service-account key is step-up gated, so the command needs your
# password as well as your session. Keep it out of shell history.
export PLATFORM_PASSWORD='…'
# 1. The org's full-privilege automation credential (synth/deploy, plugin lookup)
pipeline-manager infra store-token --days 30 --schedule --region us-east-1
# 2. The CI registry credential CodeBuild presents as Basic auth (REQUIRED:
# synth wires this secret into every build image's pull credentials)
pipeline-manager infra store-token --scope registry:push --schedule --region us-east-1
# 3. The event-ingestion credential
pipeline-manager infra store-token --scope reporting:ingest --schedule --region us-east-1
Each run writes its own secret and its own rotation stack:
| Secret | Service account | Key scope |
|---|---|---|
pipeline-builder/{orgId}/platform |
platform-automation |
none (org admin Roles) |
pipeline-builder/{orgId}/registry-push |
registry-push |
registry:push |
pipeline-builder/{orgId}/reporting-ingest |
reporting-ingest |
reporting:ingest |
On a headless host with no browser, export
PLATFORM_IDENTIFIER/PLATFORM_PASSWORDinstead:store-tokenstill has a non-interactive password login of its own for exactly that case (it is provisioning a machine credential, with nobody present to approve anything).PLATFORM_PASSWORDis required either way — it is the step-up factor for both writes.
By default infra store-token only writes the secret — you must re-run it before the
key expires (audit tokens warns you in advance). To avoid that, add --schedule
to also deploy a small daily key-rotation stack (pipeline-builder-token-renew):
# Custom rotation time (5-field cron; minimum every 15 minutes):
pipeline-manager infra store-token --schedule --cron '0 3 * * *' --region us-east-1
The rotation stack is a scheduled Lambda that mints a sibling key on the same
account, writes it to the secret, and only then retires its predecessor — in that
order, so a failure at any step leaves a working credential behind. It installs
nothing at runtime. See
Authentication → Self-rotation.
(The --with-events provision bundle opts into --schedule automatically, since
the event-ingestion Lambda depends on its key.)
Set up pipeline execution reporting to track success rates, stage performance, and build analytics:
export PLATFORM_BASE_URL=https://pipeline.example.com
pipeline-manager infra setup-events --region us-east-1
This creates a CloudFormation stack (pipeline-builder-events) containing:
The Lambda runs inside your AWS account and forwards only pipeline-execution telemetry — enough to compute success rates, stage/action timing, and DORA metrics.
Not forwarded — stays in your AWS account:
- Your AWS account number — explicitly stripped from every event (
delete detail.account).- The pipeline ARN (
arn:aws:codepipeline:<region>:<account>:<name>) — built only as a transient handle to resolve the pipeline’spb.pipeline-idtag viacodepipeline:ListTagsForResource, then discarded; never stored or sent.- AWS credentials / IAM and any account-identifying details.
The platform stores no AWS account id anywhere (schemas, JWTs, and APIs are account-id-free by design), so there is nothing to mask.
Forwarded payload (POST /api/reports/events, batched):
| Field | Notes |
|---|---|
pipelineId |
The platform pipeline id (from the pb.pipeline-id tag) — not the ARN |
eventSource / eventType |
codepipeline · PIPELINE/STAGE/ACTION |
status |
CodePipeline state (SUCCEEDED/FAILED/…) |
executionId · stageName · actionName |
Execution GUID + stage/action names |
errorMessage |
Human-readable failure summary (capped), on failures |
startedAt · completedAt · durationMs |
Timing |
commitSha · commitRef · commitTimestamp · commitCount |
Source revision + commit time/count for measured lead time (DORA), when --with-dora is enabled |
environment |
The deploy stage’s env, from the pipeline’s pb.deploys tag |
detail |
Raw CodePipeline event detail with account removed (log URL / error code for drill-down) |
Reference: @pipeline-builder/pipeline-events.
# Check the EventBridge stack
aws cloudformation describe-stacks --stack-name pipeline-builder-events \
--query 'Stacks[0].StackStatus' --output text
# Check the EventBridge rule
aws events describe-rule --name pipeline-builder-codepipeline-events
# Check the Lambda
aws lambda get-function --function-name pipeline-builder-event-ingestion \
--query 'Configuration.LastModified'
Synth → CDK tags the CodePipeline `pb.pipeline-id=<pipelineId>` (+ `pb.deploys`) (stable, set at creation)
Deploy → pipeline-manager registers the pipeline (by pipelineId) in pipeline_registry
Execute → CodePipeline runs → EventBridge captures state changes
Ingest → SQS → Lambda resolves the pb.pipeline-id tag → POST /api/reports/events (keyed by pipelineId)
Store → Reporting API matches the registry by pipelineId → inserts into pipeline_events
View → Dashboard Reports page or GET /api/reports/...
The pipeline ARN and AWS account number never leave AWS — the Lambda attributes events via the pipeline’s
pb.pipeline-idtag (= the opaquepipelineId), so nothing sensitive is stored and there is no masking key to manage. The Lambda’s execution role needscodepipeline:ListTagsForResource.
Plugin Docker builds are captured automatically by the plugin service (no EventBridge needed).
audit stacks)The pipeline_registry table is written only when pipeline-manager pipeline deploy succeeds. CloudFormation stacks can be created or destroyed outside of that path — manual aws cloudformation delete-stack, console operations, side-channel deploys — and over time the registry can drift from reality.
The audit stacks command joins the registry against live CloudFormation stacks tagged pipeline-builder and surfaces two categories of drift:
| Finding | Meaning | Typical cause |
|---|---|---|
| Orphaned stack | Tagged stack exists in CloudFormation, but no matching row in pipeline_registry |
Pipeline was deleted from the dashboard but the CDK stack stayed in AWS |
| Missing stack | Registry row exists, but no matching CloudFormation stack | Stack was deleted manually (e.g. aws cloudformation delete-stack) without going through the platform |
# Scan all orgs in the default region
pipeline-manager audit stacks --region us-east-1
# Scan one org, JSON output (suitable for piping into jq / cron alerting)
pipeline-manager audit stacks --org acme --region us-east-1 --json
# With a specific AWS profile
pipeline-manager audit stacks --profile production --region us-east-1
Flags:
| Flag | Purpose |
|---|---|
--region <region> |
AWS region to scan. Defaults to AWS_REGION env, then CDK_DEFAULT_REGION, then us-east-1. |
--org <orgId> |
Restrict both the registry fetch and the stack scan to a single org. |
--profile <profile> |
AWS CLI profile (default: default). |
--json |
Emit a single JSON document instead of human output. |
The command is designed to be cron-friendly:
| Exit code | Meaning |
|---|---|
0 |
No drift |
1 |
One or more findings (orphaned and/or missing stacks) |
2 |
AWS error or scan failure |
A typical alerting setup runs the audit nightly and pages on non-zero exit:
# /etc/cron.d/pipeline-builder-audit
0 6 * * * deploy-bot pipeline-manager audit stacks --region us-east-1 --json > /var/log/pb-audit.json || alert-on-call "pipeline-builder drift detected"
Drift is not auto-fixed — the command only reports. Reconciliation is manual and depends on the cause:
aws cloudformation delete-stack --stack-name <name> to clean up the leftover. If the deletion was unintentional, recreate the pipeline definition and redeploy.pipeline-manager pipeline deploy --id <pipelineId>) to recreate the stack and refresh the registry row. There is currently no API or dashboard surface to drop a stale registry row in isolation — if redeploy isn’t desired, the row must be removed directly in Postgres (DELETE FROM pipeline_registry WHERE pipeline_id = '<pipelineId>').--region. Run once per region you deploy to.aws cloudformation detect-stack-drift for that.*_COMPLETE statuses are considered active. A stack stuck in CREATE_IN_PROGRESS or ROLLBACK_FAILED will look like a missing stack.All endpoints require authentication and org context. Time range defaults to last 30 days.
| Endpoint | Description | Query Params |
|---|---|---|
GET /api/reports/execution/count |
Execution count per pipeline with status breakdown | — |
GET /api/reports/execution/success-rate |
Pass/fail rate over time | interval, from, to |
GET /api/reports/execution/timeline |
Execution timeline (alias for success-rate) | interval, from, to |
GET /api/reports/execution/duration |
Average/min/max/p95 execution duration | from, to |
GET /api/reports/execution/stage-failures |
Stage failure heatmap | from, to |
GET /api/reports/execution/stage-bottlenecks |
Slowest stages per pipeline | from, to |
GET /api/reports/execution/action-failures |
Action/step failure rate | from, to |
GET /api/reports/execution/errors |
Error categorization (top N) | from, to, limit |
| Endpoint | Description | Query Params |
|---|---|---|
GET /api/reports/plugins/summary |
Plugin inventory (total/active/public/private) | — |
GET /api/reports/plugins/distribution |
Type and compute distribution | — |
GET /api/reports/plugins/versions |
Version counts per plugin name | — |
GET /api/reports/plugins/build-success-rate |
Docker build success rate over time | interval, from, to |
GET /api/reports/plugins/build-duration |
Build time per plugin | from, to |
GET /api/reports/plugins/build-failures |
Build failure reasons (top N) | from, to, limit |
GET /api/reports/plugins/runtime-success-rate |
Runtime success rate per plugin version (pipeline runs) | from, to, name, publisher, version |
GET /api/reports/plugins/runtime-duration |
Runtime p50/p95 duration per plugin version | from, to, name, publisher, version |
Plugin runtime telemetry. The runtime reports describe how plugins behave when your pipelines run them. The build reports cover building the plugin images. In the dashboard, Reports → Plugins → Runs shows both runtime reports as one table per plugin version (runs, success rate, p50/p95 duration, last run), with CSV export. Here is how the data gets there:
pipeline-manager pipeline deploy synthesizes a pipeline, it records a step manifest: which plugin (publisher, name, version, image digest) each CodePipeline stage and action runs. It writes it as pb-step-manifest.json in the cloud assembly.POST /api/pipelines/registry registration. The platform re-reads name, version and digest from the plugin row (the CLI’s claim isn’t trusted), then replaces the pipeline’s rows in pipeline_step_manifests.(pipeline, stage, action). It stamps plugin_publisher, plugin_name and plugin_version onto pipeline_events.SUCCEEDED or FAILED. Canceled and superseded actions aren’t counted.publisher is pipeline-builder for the Official catalog and empty for your org’s own plugins. To select only your own plugins, pass ?publisher= with an empty value.?includeDescendants=true with reports:rollup) and capped by your retention window.Common query parameters:
| Param | Values | Default |
|---|---|---|
interval |
day, week, month |
week |
from |
ISO 8601 timestamp | 30 days ago |
to |
ISO 8601 timestamp | now |
limit |
integer | 20 |
After deployment, access services at:
| Service | Path |
|---|---|
| Application | / |
| Reports Dashboard | /dashboard/reports |
| Observability (native) | /dashboard/observability |
| Registry UI | /dashboard/registry (system-admin only) |
| PgAdmin | /pgadmin/ — only with ADMIN_UIS_ENABLED=true |
| Mongo Express | /mongo-express/ — same |
| Grafana | /grafana/ — same (then its own login, GRAFANA_ADMIN_USER / GRAFANA_ADMIN_PASSWORD) |
| Kiali (mesh graph) | /kiali/ — same (read-only) |
| RustFS console (object store) | :9001, not a path — https://<domain>:9001/rustfs/console/ on ec2; on eks kubectl port-forward -n pipeline-builder svc/nginx 9001:9001 (same ADMIN_UIS_ENABLED gate) |
The admin consoles are off by default on AWS: their routes 404. With
ADMIN_UIS_ENABLED=true in .env (re-run setup), every request to them first
passes an nginx auth_request to platform GET /admin/console-check — a live
session of a platform administrator at AAL2 — with the token taken from the
pb_admin_console cookie and stripped before the console sees the request. For
occasional use prefer kubectl -n pipeline-builder port-forward svc/grafana 3000.
The RustFS object-store console rides the same flag and the same
auth_request, but on its own port rather than a path, because its UI resolves
its API endpoints from window.location, and its S3 client uses the bare origin —
ListBuckets is GET / and objects are /<bucket>/..., with the admin API at
/rustfs/admin/v3/*. Mounted under /rustfs/console/ on the gateway its assets
would load and every one of those calls would hit the platform instead. On ec2 the ALB gets a second HTTPS listener on 9001
(same ACM cert) bridged to the nginx NodePort 30901, so it is
https://<domain>:9001/rustfs/console/. On eks there is deliberately no such
listener: an ALB rule cannot be scoped to a listener, and the alternatives
(group.name IngressGroup, or a second LoadBalancer Service) are unverified on
EKS Auto Mode’s own ALB controller — a silently ignored group.name would
provision a second ALB on a DNS name the cert does not cover. nginx still serves
the port, so reach it with
kubectl port-forward -n pipeline-builder svc/nginx 9001:9001. Either way it can
LIST and DELETE objects in the registry, Loki, Thanos, plugin-build and
audit-chain buckets, so treat it as at least as powerful as /pgadmin/.
After every provision the setup scripts run deploy/bin/post-provision-smoke.sh:
a test alert through Alertmanager to Slack, a test email through platform, a
CodePipeline credential dry-run from the pipeline pod, and a probe that a
connection the NetworkPolicies deny is actually denied. Its warnings are
non-fatal — read the summary line.