@ossy/deployment-tools
v3.18.0
Published
Collection of scripts and tools to aid deployment of containers and static files to Amazon Web Services through GitHub Actions
Downloads
2,019
Readme
@ossy/deployment-tools
Infrastructure and CDK tooling for the Ossy platform.
Architecture (epic #553): CloudFront → WAF → ALB → ECS Fargate, with per-service Secrets Manager and GitHub Actions rollouts via OIDC. EC2 + Caddy + EIP are removed from CDK (#560).
Architecture
Internet → CloudFront (ACM TLS, us-east-1) → WAF → ALB (:80)
├── host rules from platforms.json `services[].hosts|domain`
│ e.g. ossy.se → website-ossy Fargate service
├── other known hosts / *.ossy.se → platform-runtime Fargate service
└── default action → platform-runtime- Website images include the API — no separate
ossy-apiservice. - Task definitions inject env from Secrets Manager (
{platform}/{serviceKey}) and pull images from GHCR using{platform}/ghcr-pull. - Health checks use
GET /healthon each ALB target group and each ECS container (Nodefetchprobe; no curl innode:*-slim). - App CloudFront is separate from the
/mediaCloudFront instorage-static. - Viewer
Hostis forwarded to the ALB (OriginRequestPolicy.ALL_VIEWER) so host rules match. - ACM terminates TLS at CloudFront; Caddy is not part of this path.
- Route53 for known domains aliases to CloudFront (A + AAAA), not an Elastic IP.
Customer nameserver / custom-domain handoff at a registrar is out of scope (follow-up).
Health checks (ALB / ECS)
Every deployable Ossy HTTP image exposes an unauthenticated liveness probe at GET /health (also accepts HEAD). It returns 200 when the Node process can accept traffic. The JSON service field prefers OSSY_SERVICE_NAME (set on the task definition to the service key).
| Image | How /health is provided |
|---|---|
| ghcr.io/ossy-se/platform (runtime) | Built into @ossy/platform startRuntime — does not load a CMS site |
| Website / app images (website-ossy, other services[]) | Built into @ossy/platform startServer (and/or a local health.api.js until the platform package is bumped) |
ALB target groups probe GET /health. ECS container health checks run an exec-form Node one-liner (ecsContainerHealthCheckCommand) that fetches the same path on PORT and aborts after HEALTHCHECK_FETCH_TIMEOUT_MS (4s). Do not point probes at /, /status, or authenticated routes.
Task definitions also set plain env PORT, OSSY_SERVICE_NAME, NODE_ENV, media bucket vars, and GEOLITE2_COUNTRY_MMDB (/tmp/GeoLite2-Country.mmdb, #804) via containerEnvironment. Those keys are filtered out of Secrets Manager injection (secretEnvKeysForEcsContainer) so ECS cannot reject duplicate environment/secret keys.
Configuration — platforms.json
packages/infrastructure/platforms.json is the single source of truth for a platform. Each entry becomes a CDK Stage with its own set of stacks.
| Field | Required | Description |
|---|---|---|
| platformName | Yes | Unique name, used as the CDK stage name and CloudFormation stack prefix |
| awsAccountId | Yes | AWS account ID |
| awsRegion | No | Defaults to eu-north-1 |
| sesDomains | No | Domains to configure SES email identity for |
| domains | No | Domains for Route53 aliases → CloudFront and ACM coverage. Supports wildcards (e.g. *.ossy.se). |
| services | No | Per-platform HTTP container services (see below) |
| dnsRecords | No | Additional DNS records by root domain (currently supports MX) |
| env | No | Env vars upserted into per-service Secrets Manager secrets (npm run sync-secrets). Never uploaded to S3. Values remain in git for now. |
| githubDeployRepos | Yes (for platform-ci) | owner/repo list trusted by the GitHub OIDC deploy role. Set in platforms.json (e.g. ossy-se/ossy, ossy-se/website-ossy, ossy-se/website-plexus-sanitas). |
| awsKeyPairName / awsInstanceClass / awsInstanceSize | No | Unused after EC2 decommission (#560). Harmless if left in JSON. |
services — HTTP containers on ECS
HTTP services (default) become Fargate services with ALB host rules. The container must listen on port 3000.
"services": [
{
"name": "ossy-website-ossy",
"domain": "ossy.se",
"image": "ghcr.io/ossy-se/website-ossy:latest",
"hosts": ["ossy.se"]
}
]| Field | Description |
|---|---|
| name | Unique service name — Secrets Manager / ECS key strips a leading ossy- (website-ossy). |
| domain | Primary hostname (default ALB host when hosts is omitted). |
| hosts | Optional ALB host-header list. Example: ["ossy.se"] so the website image serves the apex. Defaults to [domain]. |
| image | Docker image to pull and run (GHCR). |
Built-in (always present):
runtime— platform runtime (ghcr.io/ossy-se/platform:latest) serving CMS-published apps; ALB default + unclaimeddomains.
Not deployed: separate ossy-api container; TCP/UDP services[] entries (ignored by the ECS path).
Wildcard subdomains: Add *.ossy.se to domains for a Route53 wildcard alias to CloudFront.
MEDIA_REPOSITORY and MEDIA_CDN_DOMAIN_NAME are derived from the provisioned S3 bucket and media CloudFront — do not set them in env.
To add a new platform, add an entry to platforms.json. No bucket name or CDN domain is needed; these are auto-generated by CloudFormation.
Platform secrets (Secrets Manager)
Each platform service gets one Secrets Manager secret (JSON object of env keys). Values still live in platforms.json for now; CDK owns the secret resources and IAM, and deploy upserts values.
| Secret name | Source |
|---|---|
| {platform}/runtime | Built-in platform-runtime |
| {platform}/website-ossy | HTTP entry in services[] named ossy-website-ossy (leading ossy- stripped) |
| {platform}/… | Other HTTP services[] entries |
Not created: ossy-api. TCP/UDP services[] entries are skipped.
Each secret has a matching IAM role {platform}-{key}-task assumed by ecs-tasks.amazonaws.com with read access to that secret only.
Also created: {platform}/ghcr-pull — Docker Hub-style credentials JSON (username + password PAT with read:packages) for private GHCR pulls. Not filled by sync-secrets; set manually once after the secrets stack deploys.
Sync values after CDK deploy
cd packages/deployment-tools
# Create / update secret resources + IAM (part of the stage)
npx cdk deploy 'ossybot/platform-secrets' --profile ossybot
# Upsert secret *values* from platforms.json (never prints secret contents)
npm run sync-secrets -- --profile ossybot
# Optional: one platform, or dry-run
npm run sync-secrets -- --profile ossybot --platform ossybot
npm run sync-secrets -- --dry-runDo not remove secrets from platforms.json yet.
ECS Fargate + ALB
cd packages/deployment-tools
# Secrets + IAM first, then sync values + set ghcr-pull
npx cdk deploy 'ossybot/platform-secrets' --profile ossybot
npm run sync-secrets -- --profile ossybot
# then put GHCR credentials into ossybot/ghcr-pull
# VPC, cluster, Fargate services, ALB host rules
npx cdk deploy 'ossybot/platform-ecs' --profile ossybot| Resource | Notes |
|---|---|
| VPC | Public + private subnets, 1 NAT gateway |
| ECS cluster | {platform}-platform |
| Fargate services | runtime (ghcr.io/ossy-se/platform:latest) + each HTTP services[] entry |
| ALB | Internet-facing HTTP :80; host rules; default → runtime; health check /health |
| Task env | Secrets Manager JSON keys from platforms.json env; MEDIA_* from storage stack |
CloudFront + WAF + ACM
App traffic uses its own CloudFront distribution (do not overload the /media CDN). The edge stack is deployed in us-east-1 because CloudFront viewer certificates and CLOUDFRONT-scope WAFv2 web ACLs must live there.
# After platform-ecs exists (ALB DNS used as origin)
npx cdk deploy 'ossybot/platform-edge' --profile ossybot
# DNS aliases for known domains → CloudFront
npx cdk deploy 'ossybot/dns' --profile ossybot| Piece | Detail |
|---|---|
| Origin | ALB DNS name from platform-ecs (LoadBalancerDnsName), HTTP :80 |
| Certificate | ACM in us-east-1, DNS-validated; covers domains + HTTP service hosts (apex + *.ossy.se etc.) |
| WAF | WAFv2 web ACL (AWSManagedRulesCommonRuleSet + KnownBadInputs) associated via webAclId |
| Cache | CACHING_DISABLED + ALL_VIEWER origin request policy (forwards Host) |
| DNS | Route53 alias A/AAAA for known domains → CloudFront |
Release to ECS from GitHub Actions
Images stay on GHCR. After a push to main, Publish workflows tag the image with the git SHA (and :latest), assume the platform deploy role via GitHub OIDC (no long-lived AWS keys), register a new task definition revision with the SHA tag, and update the ECS service.
# After platform-ecs exists — creates OIDC provider + deploy role
npx cdk deploy 'ossybot/platform-ci' --profile ossybot| Piece | Detail |
|---|---|
| Role | {platform}-github-deploy (e.g. ossybot-github-deploy) |
| Trust | OIDC token.actions.githubusercontent.com, sub = repo:<owner>/<repo>:ref:refs/heads/main for each entry in platforms.json → githubDeployRepos |
| Cluster / services | {platform}-platform / {platform}-{serviceKey} (e.g. ossybot-runtime, ossybot-website-ossy) |
| Image tags | ghcr.io/ossy-se/<image>:latest and :<git-sha> |
| Workflows | each trusted repo’s Publish workflow rolls its ECS service |
Add another deploy source by appending owner/repo to githubDeployRepos and redeploying platform-ci. Deploy that stack once before the first Actions rollout. Workflows wait for ECS service stability after update.
CDK Stacks
Each platform is a CDK Stage containing these stacks:
| Stack | Description |
|---|---|
| storage-static | S3 bucket (auto-named), daily backup plan, CloudFront CDN for /media. |
| platform-secrets | Secrets Manager secrets per service + ECS task roles (read IAM) + ghcr-pull. Values synced from platforms.json via npm run sync-secrets. |
| platform-ecs | VPC, ECS Fargate services, ALB with host-based routing. |
| platform-ci | GitHub OIDC provider + {platform}-github-deploy IAM role for ECS rollouts from Actions. |
| platform-edge | CloudFront + WAF + ACM in us-east-1 in front of the ALB. |
| dns | Route53 alias records for known domains → CloudFront app distribution. |
| ses | SES email sending identity and DKIM records. |
Cutover / decommission EC2 (#560)
CDK no longer synthesizes deployment-target (EC2 + EIP + Caddy + systemd units). After platform-edge and dns are live and traffic is healthy:
# Destroy the leftover CloudFormation stack (name may be stage-prefixed)
npx cdk destroy 'ossybot/deployment-target' --profile ossybot
# or, if the stack still exists outside the app:
# aws cloudformation delete-stack --stack-name ossybot-deployment-target --profile ossybotDowntime during cutover is acceptable. Confirm known domains resolve to CloudFront before destroying EC2.
Bootstrapping a new platform
cd packages/deployment-tools
npx cdk deploy 'ossybot/**' --profile ossybot --require-approval never
npm run sync-secrets -- --profile ossybot
# set {platform}/ghcr-pull credentials onceDeploying changes
Target a single platform:
npx cdk deploy 'ossybot/**' --profile ossybotTarget all platforms:
npx cdk deploy '**' --profile ossybotPreview changes without deploying:
npx cdk diff 'ossybot/**' --profile ossybotList all stacks:
npx cdk listRedeploying after a code change
Merge to main — Publish workflows push GHCR tags (:latest + :<sha>) and roll the matching ECS service. No SSH / systemctl.
Environment variables
Defined in the env block of platforms.json, synced into Secrets Manager. The following are injected automatically by the ECS stack and should not be set manually:
| Variable | Source |
|---|---|
| MEDIA_REPOSITORY | S3 bucket name from storage-static stack |
| MEDIA_CDN_DOMAIN_NAME | CloudFront domain from storage-static stack |
Other common env vars:
| Variable | Used by | Description |
|---|---|---|
| DB_URL | API | Atlas MongoDB connection string |
| DB_NAME | API | MongoDB database name |
| TOKEN_SECRET | API | JWT signing secret |
| AWS_ACCESS_KEY_ID | API | AWS credentials for S3 |
| AWS_SECRET_ACCESS_KEY | API | AWS credentials for S3 |
| OSSY_API_KEY | Platform, websites | API JWT for CMS reads |
| OSSY_COOKIE_SECRET | Websites | Cookie signing secret |
| GEOLITE2_COUNTRY_MMDB | App (@ossy/analytics location projection) | Absolute path to MaxMind GeoLite2-Country .mmdb (#769 / #804). Set on every ECS task as /tmp/GeoLite2-Country.mmdb. startServer downloads the file there when MAXMIND_LICENSE_KEY is set. Optional; without the file countryCode is omitted (IP still stored on the event). |
| MAXMIND_LICENSE_KEY | App (startServer GeoLite2 download) | MaxMind license key. Add to platforms.json env and npm run sync-secrets so every website image (ossy.se, plexus-sanitas, …) can download GeoLite2-Country at boot (#804). Free signup: https://www.maxmind.com/en/geolite2/signup |
| MAXMIND_ACCOUNT_ID | App (startServer GeoLite2 download) | Optional MaxMind account id. When set with the license key, download uses the permalink + HTTP Basic auth API. |
| OSSY_TRUST_PROXY_HOPS | Platform / websites | Express trust proxy hop count behind CloudFront→ALB (default: 2). |
| OPENROUTER_API_KEY | App / API (@ossy/media-tasks) | OpenRouter API key for LLM tasks (e.g. visual-content-descriptors). Add to platforms.json env and run npm run sync-secrets. Optional: OPENROUTER_SITE_URL, OPENROUTER_APP_TITLE, OPENROUTER_BASE_URL. |
Useful commands
cd packages/deployment-tools
npm test
npx cdk list
npx cdk diff 'ossybot/**' --profile ossybot
npm run sync-secrets -- --profile ossybot --dry-run