Compare commits

...
Author SHA1 Message Date
Anthony Stirling 60c81e20d0 Add cluster deployment artifacts and Helm chart 2026-05-27 12:21:04 +01:00
24 changed files with 3019 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
# AWS deployment - Stirling-PDF clustered
Three paths, ordered by ease-of-use:
| Path | Best for | Time-to-live | Monthly cost (us-east-1) | Scales? |
|---|---|---|---|---|
| **1. CloudFormation one-click** | Enterprise self-host, no Kubernetes | ~15 min | ~$120-160 | Yes (ECS autoscaling) |
| **2. Terraform module** | Your SaaS, env-promotable, GitOps | ~20 min first time | ~$120-160 | Yes (ECS autoscaling) |
| **3. EC2 + Docker Compose** | ≤25 concurrent users, single VM, dead simple | ~5 min | ~$25-40 | No (one VM) |
Already have a Kubernetes cluster (EKS)? Use the existing
[`deploy/helm/stirling-pdf/`](../helm/stirling-pdf/) chart instead of any of
these.
## What each deploys
All three deliver the same logical topology: **N Stirling app instances behind
a load balancer, sharing a Valkey backplane and a Postgres database**, with
`/internal/*` blocked at the LB.
```
Internet
┌────────────────┐
│ ALB / nginx │ ←── blocks /internal/*
└────────────────┘
│ │
▼ ▼
┌────────────────┐
│ app-1 app-2 │ ←── CLUSTER_ENABLED=true
└────────────────┘ CLUSTER_BACKPLANE=valkey
│ │
▼ ▼
Valkey Postgres
(ElastiCache) (RDS)
```
### Sticky sessions are required
Every LB config in this repo (`ip_hash` in nginx, `lb_cookie` on the ALB,
`affinity: cookie` on the k8s ingress) is pinning sessions deliberately, not as
an optimisation. Result PDFs are written to the local disk of whichever node
ran the job; without affinity a download has a ~50% chance of landing on a
non-owner node and getting a 410 Gone. Cookie / IP affinity pins a returning
client back to the owner pod. If you fork an LB config, keep the stickiness.
**Heads-up on `ip_hash` (nginx Docker Compose path only):** `ip_hash` collapses
every client sharing a source IP onto the same backend. Behind a corporate VPN,
CGNAT, or a single egress NAT this means all of those users hammer one app
container while the others sit idle. The Compose path is fine for small
deployments (the doc above caps it at 25 concurrent users on one VM), but for
diverse client populations move to cookie-based affinity (nginx-plus or the
openresty sticky-cookie module) or use one of the managed LB paths above
- the ALB / k8s Ingress configs in this directory already use cookie stickiness
for exactly this reason.
The only thing that changes between paths is **who manages Valkey and Postgres**:
| Path | App tasks | Valkey | Postgres | LB |
|---|---|---|---|---|
| CloudFormation | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
| Terraform | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
| EC2 Compose | Docker on one EC2 | Docker container | Docker container | nginx (Docker) |
## Path 1 - CloudFormation (recommended for enterprises)
The template no longer accepts plaintext passwords as parameters - that pattern leaked
secrets into the rendered stack template, CloudTrail events, and the template S3 bucket
even with `NoEcho`. Operators pre-create the password secrets and pass their ARNs:
```bash
DB_SECRET_ARN=$(aws secretsmanager create-secret \
--name stirling/db-password \
--secret-string "$(openssl rand -base64 24)" \
--query ARN --output text)
VALKEY_SECRET_ARN=$(aws secretsmanager create-secret \
--name stirling/valkey-auth \
--secret-string "$(openssl rand -hex 32)" \
--query ARN --output text)
aws cloudformation deploy \
--stack-name stirling-pdf \
--template-file cloudformation/stirling-pdf-aws.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
DbSecretArn=$DB_SECRET_ARN \
ValkeyAuthSecretArn=$VALKEY_SECRET_ARN
```
The X-Engine-Auth token is generated *inside* Secrets Manager by CloudFormation - the
operator never sees or supplies it. After deploy it can be retrieved with
`aws secretsmanager get-secret-value --secret-id <stack-name>-engine-secret`.
Or click-deploy via the AWS console: **Console → CloudFormation → Create stack
→ Upload `cloudformation/stirling-pdf-aws.yaml` → fill the 2 ARN params**.
The stack output `AppUrl` gives you the URL.
**Knobs you can change in the parameters:**
| Param | Default | Notes |
|---|---|---|
| `AppCount` | 2 | Initial Fargate task count; autoscaling can grow to 10 |
| `AppCpu` | 1024 (1 vCPU) | Per task |
| `AppMemory` | 4096 MiB | Per task |
| `EnableAiEngine` | false | Turn on if using AI features |
| `AppImage` | `stirlingtools/stirling-pdf:2.11.0` | Pinned to release. Swap for your own ECR URI or newer tag |
| `EngineImage` | `stirlingtools/stirling-pdf-ai-engine:2.11.0` | Same |
| `DbSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Postgres password |
| `ValkeyAuthSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Valkey AUTH token |
**Tear-down:** `aws cloudformation delete-stack --stack-name stirling-pdf`. RDS
keeps a final snapshot for safety.
## Path 2 - Terraform (recommended for your SaaS)
```bash
cd terraform
cat > terraform.tfvars <<EOF
name = "stirling-prod"
region = "us-east-1"
app_count = 3
db_password = "$(openssl rand -base64 24)"
engine_shared_secret = "$(openssl rand -hex 32)"
valkey_auth_token = "$(openssl rand -hex 32)"
EOF
terraform init
terraform apply
```
The ElastiCache replication group runs with TLS in flight (`rediss://`) and AUTH enabled.
`valkey_auth_token` is required - the application connects with `REDIS_PASSWORD` populated
from Secrets Manager so a compromised pod cannot sweep the keyspace.
Tearing down: `terraform destroy`.
The Terraform module is intentionally a **single file** to keep it easy to
fork. For real production you'd split into `modules/{vpc,ecs,rds,elasticache,alb}`
and have a separate `envs/{dev,staging,prod}/main.tf` referencing them. Both
shapes work; the single file is the starting point.
## Path 3 - EC2 + Docker Compose
See [`quickstart/ec2-compose.md`](quickstart/ec2-compose.md).
## EKS + Helm (for k8s shops)
The Phase 1 implementation already ships a Helm chart:
```bash
# Assumes you already have an EKS cluster
helm install stirling deploy/helm/stirling-pdf/ \
--set cluster.engineSharedSecret=$(openssl rand -hex 32) \
--set image.repository=<your-ecr-uri>/stirling-pdf
```
The chart includes a Valkey StatefulSet by default. To use ElastiCache instead:
```bash
helm install stirling deploy/helm/stirling-pdf/ \
--set cluster.valkey.bundled=false \
--set cluster.valkey.externalUrl=redis://your-elasticache:6379 \
...
```
## Required AWS permissions (for whoever runs the deploy)
Minimum set to apply the CloudFormation/Terraform:
- `ec2:*`, `elasticloadbalancing:*` (VPC + ALB)
- `ecs:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `iam:PutRolePolicy`,
`iam:PassRole`
- `elasticache:*`
- `rds:*`
- `secretsmanager:*`
- `logs:CreateLogGroup`, `logs:PutRetentionPolicy`
- `application-autoscaling:*`
- `cloudformation:*` (CFN only)
Easiest: run as a user with `PowerUserAccess` for the initial bootstrap, then
narrow down with IAM Access Analyzer after the stack is up.
## Sizing rules of thumb
| Concurrent users | App tasks | App size | Valkey | Postgres |
|---|---|---|---|---|
| ≤25 | 2 | 1 vCPU / 4 GB | cache.t4g.small | db.t4g.micro |
| 25-100 | 3 | 2 vCPU / 4 GB | cache.t4g.small | db.t4g.small |
| 100-500 | 4-6 | 2 vCPU / 8 GB | cache.t4g.medium | db.t4g.medium |
| 500+ | 6+ (autoscale) | 2 vCPU / 8 GB | cache.r7g.large + replica | db.t4g.large + read replica |
The defaults in the templates suit ≤25 users; bump `AppCount` /
`InstanceClass` to grow.
## What you still need to set up yourself
These are not in the templates because they're customer-specific:
- **Custom domain + HTTPS.** ALB listener on port 443 with an ACM cert (5-min
console wizard) + a Route 53 A-record alias to the ALB.
- **Email (SES) for password reset / invitations.**
- **OAuth/SAML identity provider.** Stirling supports Keycloak, Okta, Azure
AD, Google - config goes in `settings.yml`.
- **Backups for Valkey.** Optional - ElastiCache supports daily snapshots; turn
on `SnapshotRetentionLimit` in the template if you want. Phase 1 state in
Valkey is short-TTL so most operators skip it.
- **Frontend CDN.** Not required; the app serves static assets fine. If you
want CloudFront, point it at the ALB and cache `/static/*`.
## Common questions
**Why ECS Fargate, not EKS?** Fargate has zero cluster management - no
control plane to maintain. EKS gets cheaper at scale but adds k8s ops. For
most enterprises Fargate is the right default.
**Why ElastiCache for Valkey and not just Redis?** Valkey is the Linux
Foundation's BSD-licensed Redis fork; AWS ElastiCache has supported it natively
since 2024. Same wire protocol. Stirling's `LettuceConnectionFactory` doesn't
care which one - pick whichever your security team is happier with.
**Why RDS Postgres and not Aurora?** Aurora costs ~3× more for the same TPS in
this workload (mostly cold reads for user/team tables). Switch to Aurora if
you need read replicas + DR; t4g.micro covers the small-tenant case.
**What if I want to use my company's existing Postgres / Redis?** Run only
the ECS part of the CloudFormation by setting `SPRING_DATASOURCE_URL` and
`CLUSTER_VALKEY_URL` in the task definition to point at your
existing endpoints. The template doesn't currently expose those as parameters
- easy fork.
**My ops team wants Pulumi / CDK / Crossplane.** All three speak the same
underlying APIs the CloudFormation template uses. Translate from the YAML -
the resource graph is identical.
@@ -0,0 +1,507 @@
AWSTemplateFormatVersion: "2010-09-09"
Description: >
Stirling-PDF clustered deployment on AWS. Provisions an ECS Fargate service
(2+ app tasks) + ElastiCache for Valkey + RDS PostgreSQL + ALB. Single
CloudFormation stack - click Launch Stack and fill in 4 fields.
Cost estimate (us-east-1, default sizing): ~$120-160 / month.
Tear-down: delete the stack. Everything except the RDS final snapshot goes.
Parameters:
AppImage:
Type: String
Default: stirlingtools/stirling-pdf:2.11.0
Description: Container image with the Phase 1 cluster code. Use your ECR URI for private builds. Pin to an exact version - never :latest in production.
EngineImage:
Type: String
Default: stirlingtools/stirling-pdf-ai-engine:2.11.0
Description: AI engine image. Leave default if you do not use AI features. Pin to an exact version - never :latest in production.
DbSecretArn:
Type: String
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
Description: >
ARN of a Secrets Manager secret holding the Postgres password as plain SecretString.
Create: aws secretsmanager create-secret --name stirling/db-password --secret-string "$(openssl rand -base64 24)"
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
ValkeyAuthSecretArn:
Type: String
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
Description: >
ARN of a Secrets Manager secret holding the ElastiCache AUTH token (16-128 chars).
Create: aws secretsmanager create-secret --name stirling/valkey-auth --secret-string "$(openssl rand -hex 32)"
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
AppCount:
Type: Number
Default: 2
MinValue: 2
MaxValue: 10
Description: Number of Stirling-PDF app tasks. Multi-instance requires at least 2.
AppCpu:
Type: Number
Default: 1024
AllowedValues: [512, 1024, 2048, 4096]
Description: Fargate CPU units per app task (1024 = 1 vCPU).
AppMemory:
Type: Number
Default: 4096
AllowedValues: [1024, 2048, 4096, 8192]
Description: Fargate memory MiB per app task.
EnableAiEngine:
Type: String
Default: "false"
AllowedValues: ["true", "false"]
Description: Set true to also run the AI engine service.
Conditions:
WithAiEngine: !Equals [!Ref EnableAiEngine, "true"]
Resources:
# ----- networking: use default VPC + its subnets to stay simple -----
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.42.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-vpc"}]
Igw:
Type: AWS::EC2::InternetGateway
IgwAttach:
Type: AWS::EC2::VPCGatewayAttachment
Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw}
SubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [0, !GetAZs ""]
CidrBlock: 10.42.1.0/24
MapPublicIpOnLaunch: true
SubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [1, !GetAZs ""]
CidrBlock: 10.42.2.0/24
MapPublicIpOnLaunch: true
PublicRt:
Type: AWS::EC2::RouteTable
Properties: {VpcId: !Ref Vpc}
PublicRoute:
Type: AWS::EC2::Route
DependsOn: IgwAttach
Properties:
RouteTableId: !Ref PublicRt
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref Igw
RtAssocA:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: {SubnetId: !Ref SubnetA, RouteTableId: !Ref PublicRt}
RtAssocB:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: {SubnetId: !Ref SubnetB, RouteTableId: !Ref PublicRt}
# ----- security groups -----
AlbSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Public ALB
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0}
- {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0}
AppSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Stirling app tasks
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSg}
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !GetAtt InternalSelfSg.GroupId}
InternalSelfSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow app tasks to reach each other internally
VpcId: !Ref Vpc
ValkeySg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Valkey ElastiCache
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 6379, ToPort: 6379, SourceSecurityGroupId: !Ref AppSg}
DbSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: PostgreSQL
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSg}
EngineLbSg:
Type: AWS::EC2::SecurityGroup
Condition: WithAiEngine
Properties:
GroupDescription: Internal ALB in front of the engine tier - only reachable from app tasks
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref AppSg}
EngineSg:
Type: AWS::EC2::SecurityGroup
Condition: WithAiEngine
Properties:
GroupDescription: AI engine - only reachable from the internal engine LB
VpcId: !Ref Vpc
SecurityGroupIngress:
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref EngineLbSg}
# ----- managed Valkey -----
ValkeySubnetGroup:
Type: AWS::ElastiCache::SubnetGroup
Properties:
Description: Stirling Valkey subnet group
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
Valkey:
Type: AWS::ElastiCache::ReplicationGroup
Properties:
ReplicationGroupDescription: Stirling Valkey
Engine: valkey
EngineVersion: "8.0"
CacheNodeType: cache.t4g.small
NumCacheClusters: 1
AutomaticFailoverEnabled: false
CacheSubnetGroupName: !Ref ValkeySubnetGroup
SecurityGroupIds: [!Ref ValkeySg]
AtRestEncryptionEnabled: true
# TLS in flight required when AuthToken is set (ElastiCache enforces this).
TransitEncryptionEnabled: true
AuthToken: !Sub "{{resolve:secretsmanager:${ValkeyAuthSecretArn}}}"
# ----- managed Postgres -----
DbSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Stirling Postgres subnet group
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
Postgres:
Type: AWS::RDS::DBInstance
DeletionPolicy: Snapshot
UpdateReplacePolicy: Snapshot
Properties:
DBInstanceIdentifier: !Sub "${AWS::StackName}-pg"
AllocatedStorage: 20
DBInstanceClass: db.t4g.micro
Engine: postgres
EngineVersion: "17.2"
MasterUsername: stirling
MasterUserPassword: !Sub "{{resolve:secretsmanager:${DbSecretArn}}}"
DBName: stirling
DBSubnetGroupName: !Ref DbSubnetGroup
VPCSecurityGroups: [!Ref DbSg]
StorageEncrypted: true
PubliclyAccessible: false
MultiAZ: false
BackupRetentionPeriod: 7
# ----- secrets -----
EngineSharedSecret:
Type: AWS::SecretsManager::Secret
Properties:
Name: !Sub "${AWS::StackName}-engine-secret"
Description: X-Engine-Auth shared secret between app and AI engine. Generated by CFN, never passed in.
GenerateSecretString:
PasswordLength: 64
ExcludePunctuation: true
# ----- ECS cluster + roles -----
EcsCluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub "${AWS::StackName}-cluster"
ClusterSettings:
- {Name: containerInsights, Value: enabled}
TaskExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal: {Service: ecs-tasks.amazonaws.com}
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
Policies:
- PolicyName: ReadSecrets
PolicyDocument:
Statement:
- Effect: Allow
Action: [secretsmanager:GetSecretValue]
Resource:
- !Ref EngineSharedSecret
- !Ref DbSecretArn
- !Ref ValkeyAuthSecretArn
TaskLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/ecs/${AWS::StackName}"
RetentionInDays: 14
# ----- ALB -----
Alb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: internet-facing
Subnets: [!Ref SubnetA, !Ref SubnetB]
SecurityGroups: [!Ref AlbSg]
Type: application
AppTg:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !Ref Vpc
Port: 8080
Protocol: HTTP
TargetType: ip
HealthCheckPath: /api/v1/info/status
HealthCheckIntervalSeconds: 30
HealthCheckTimeoutSeconds: 10
HealthyThresholdCount: 2
UnhealthyThresholdCount: 5
Matcher: {HttpCode: "200"}
# Sticky sessions required - see deploy/aws/README.md.
TargetGroupAttributes:
- Key: stickiness.enabled
Value: 'true'
- Key: stickiness.type
Value: lb_cookie
- Key: stickiness.lb_cookie.duration_seconds
Value: '86400'
AlbListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref Alb
Port: 80
Protocol: HTTP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref AppTg
AlbBlockInternalRule:
Type: AWS::ElasticLoadBalancingV2::ListenerRule
Properties:
ListenerArn: !Ref AlbListener
Priority: 1
Conditions:
- Field: path-pattern
Values: ["/internal/*"]
Actions:
- Type: fixed-response
FixedResponseConfig:
StatusCode: "404"
ContentType: text/plain
MessageBody: "Not Found"
# ----- ECS task definition for the Stirling app -----
AppTaskDef:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub "${AWS::StackName}-app"
Cpu: !Ref AppCpu
Memory: !Ref AppMemory
NetworkMode: awsvpc
RequiresCompatibilities: [FARGATE]
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
ContainerDefinitions:
- Name: stirling
Image: !Ref AppImage
Essential: true
PortMappings:
- ContainerPort: 8080
Protocol: tcp
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref TaskLogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: app
Environment:
- {Name: CLUSTER_ENABLED, Value: "true"}
- {Name: CLUSTER_BACKPLANE, Value: valkey}
# rediss:// = TLS. REDIS_PASSWORD injected separately via Secrets below.
- {Name: CLUSTER_VALKEY_URL, Value: !Sub "rediss://${Valkey.PrimaryEndPoint.Address}:6379"}
- {Name: SPRING_DATASOURCE_URL, Value: !Sub "jdbc:postgresql://${Postgres.Endpoint.Address}:5432/stirling"}
- {Name: SPRING_DATASOURCE_USERNAME, Value: stirling}
- {Name: DOCKER_ENABLE_SECURITY, Value: "true"}
- {Name: SYSTEM_DEFAULTLOCALE, Value: en-US}
- !If
- WithAiEngine
- {Name: AIENGINE_URL, Value: !Sub "http://${EngineAlb.DNSName}:5001"}
- !Ref AWS::NoValue
Secrets:
- {Name: CLUSTER_ENGINE_SHAREDSECRET, ValueFrom: !Ref EngineSharedSecret}
- {Name: SPRING_DATASOURCE_PASSWORD, ValueFrom: !Ref DbSecretArn}
- {Name: REDIS_PASSWORD, ValueFrom: !Ref ValkeyAuthSecretArn}
AppService:
Type: AWS::ECS::Service
DependsOn: AlbListener
Properties:
ServiceName: !Sub "${AWS::StackName}-app"
Cluster: !Ref EcsCluster
DesiredCount: !Ref AppCount
LaunchType: FARGATE
TaskDefinition: !Ref AppTaskDef
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
HealthCheckGracePeriodSeconds: 120
DeploymentConfiguration:
MinimumHealthyPercent: 50
MaximumPercent: 200
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: ENABLED
Subnets: [!Ref SubnetA, !Ref SubnetB]
SecurityGroups: [!Ref AppSg, !Ref InternalSelfSg]
LoadBalancers:
- ContainerName: stirling
ContainerPort: 8080
TargetGroupArn: !Ref AppTg
# ----- optional AI engine service -----
EngineAlb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Condition: WithAiEngine
Properties:
Scheme: internal
Subnets: [!Ref SubnetA, !Ref SubnetB]
SecurityGroups: [!Ref EngineLbSg]
Type: application
EngineTg:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Condition: WithAiEngine
Properties:
VpcId: !Ref Vpc
Port: 5001
Protocol: HTTP
TargetType: ip
HealthCheckPath: /health
HealthCheckIntervalSeconds: 30
HealthCheckTimeoutSeconds: 10
HealthyThresholdCount: 2
UnhealthyThresholdCount: 5
Matcher: {HttpCode: "200"}
EngineAlbListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Condition: WithAiEngine
Properties:
LoadBalancerArn: !Ref EngineAlb
Port: 5001
Protocol: HTTP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref EngineTg
EngineTaskDef:
Type: AWS::ECS::TaskDefinition
Condition: WithAiEngine
Properties:
Family: !Sub "${AWS::StackName}-engine"
Cpu: 1024
Memory: 2048
NetworkMode: awsvpc
RequiresCompatibilities: [FARGATE]
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
ContainerDefinitions:
- Name: engine
Image: !Ref EngineImage
PortMappings: [{ContainerPort: 5001, Protocol: tcp}]
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref TaskLogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: engine
Secrets:
- {Name: STIRLING_ENGINE_SHARED_SECRET, ValueFrom: !Ref EngineSharedSecret}
EngineService:
Type: AWS::ECS::Service
Condition: WithAiEngine
DependsOn: EngineAlbListener
Properties:
ServiceName: !Sub "${AWS::StackName}-engine"
Cluster: !Ref EcsCluster
DesiredCount: 1
LaunchType: FARGATE
TaskDefinition: !Ref EngineTaskDef
# Engine model-load warm-up can take 60-90s.
HealthCheckGracePeriodSeconds: 120
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: ENABLED
Subnets: [!Ref SubnetA, !Ref SubnetB]
SecurityGroups: [!Ref EngineSg]
LoadBalancers:
- ContainerName: engine
ContainerPort: 5001
TargetGroupArn: !Ref EngineTg
# ----- HPA-equivalent: target tracking on CPU -----
AppScalingTarget:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
MaxCapacity: 10
MinCapacity: !Ref AppCount
ResourceId: !Sub "service/${EcsCluster}/${AppService.Name}"
ScalableDimension: ecs:service:DesiredCount
ServiceNamespace: ecs
RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService"
AppScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: !Sub "${AWS::StackName}-cpu-target"
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref AppScalingTarget
TargetTrackingScalingPolicyConfiguration:
TargetValue: 70
PredefinedMetricSpecification:
PredefinedMetricType: ECSServiceAverageCPUUtilization
ScaleInCooldown: 60
ScaleOutCooldown: 60
Outputs:
AppUrl:
Description: Open this URL - Stirling-PDF cluster front door
Value: !Sub "http://${Alb.DNSName}/"
ClusterName:
Value: !Ref EcsCluster
ValkeyEndpoint:
Value: !GetAtt Valkey.PrimaryEndPoint.Address
PostgresEndpoint:
Value: !GetAtt Postgres.Endpoint.Address
StackTeardown:
Description: To delete everything (RDS keeps a final snapshot)
Value: !Sub "aws cloudformation delete-stack --stack-name ${AWS::StackName}"
+108
View File
@@ -0,0 +1,108 @@
# Single-EC2 Quickstart - "just give me Docker on a VM"
The fastest possible AWS deployment when you don't want managed services. **Good
for ≤25 concurrent users on a single beefy VM**. Past that, jump to the
CloudFormation or Terraform option which scales horizontally.
## What you get
- One EC2 instance running 2 Stirling app containers, 1 Valkey, 1 Postgres,
1 nginx LB - exactly the validated `validation/compose.test.yml` topology
- ~$25-40/month for a `t3.large`
- 5-minute deploy
## Steps
### 1. Launch an EC2 instance
- AMI: Amazon Linux 2023 (or Ubuntu 22.04 LTS)
- Instance type: `t3.large` (2 vCPU, 8 GB RAM) minimum
- Storage: 30 GB gp3
- Security group: open `80/tcp` (and `22/tcp` for SSH)
- IAM role: none needed
### 2. SSH in and install Docker
```bash
sudo dnf install -y docker git
sudo systemctl enable --now docker
sudo usermod -aG docker ec2-user
# new shell so the group takes effect
exit
```
(On Ubuntu: `sudo apt install -y docker.io docker-compose-plugin git`.)
### 3. Pull the compose stack
```bash
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
cd Stirling-PDF
git checkout v2.11.0
```
### 4. Set secrets and start
```bash
export CLUSTER_ENGINE_SHAREDSECRET=$(openssl rand -hex 16)
export STIRLING_VALKEY_PASSWORD=$(openssl rand -hex 16)
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
docker compose -f docker/compose/docker-compose-cluster.yml up -d --build
```
Wait ~2 min for the apps to come up:
```bash
until curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP; do sleep 5; done
echo OK
```
### 5. Open it
`http://<EC2 public IP>/`
### 6. (Optional) HTTPS
Slap Caddy or Traefik in front, or use AWS ALB pointing at the EC2 instance.
Easiest is Caddy:
```bash
docker run -d --name caddy --restart unless-stopped \
-p 80:80 -p 443:443 \
-v caddy_data:/data \
-v $PWD/Caddyfile:/etc/caddy/Caddyfile \
caddy
```
with a tiny `Caddyfile`:
```
stirling.yourdomain.com {
reverse_proxy host.docker.internal:8080
}
```
Caddy fetches a Let's Encrypt cert automatically.
## Backups
- **Postgres**: `docker exec stirling-postgres pg_dump -U stirling stirling > backup-$(date +%F).sql`
Cron this and ship to S3 with `aws s3 cp`.
- **Valkey**: state is short-TTL (job status, rate-limit counters); no backup
needed - losing it on restart just means in-flight async jobs need re-running.
## Tear-down
`docker compose -f docker/compose/docker-compose-cluster.yml down -v` then
terminate the EC2 instance.
## Limits of this path
- Single point of failure (one VM)
- Manual scaling: edit `app-3`/`app-4` services into the compose file, restart
- No autoscaling
- No managed-service backups for Valkey
- nginx LB runs on the same VM as the apps
If any of these matter, use the CloudFormation or Terraform option in this
directory instead.
+25
View File
@@ -0,0 +1,25 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.100.0"
constraints = "~> 5.0"
hashes = [
"h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=",
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
]
}
@@ -0,0 +1,375 @@
Copyright (c) 2017 HashiCorp, Inc.
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+642
View File
@@ -0,0 +1,642 @@
# Stirling-PDF on AWS - Terraform module (single-module starting point).
# Production users typically split into modules/{vpc,ecs,rds,elasticache,alb}; this shape
# is meant as a copy-and-fill-in template.
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
# ----- inputs -----
variable "region" {
type = string
default = "us-east-1"
}
variable "name" {
type = string
default = "stirling"
}
variable "app_image" {
type = string
# Never use :latest in production - breaks reproducible deploys and rollback.
default = "stirlingtools/stirling-pdf:2.11.0"
}
variable "engine_image" {
type = string
default = "stirlingtools/stirling-pdf-ai-engine:2.11.0"
}
variable "app_count" {
type = number
default = 2
}
variable "app_cpu" {
type = number
default = 1024
}
variable "app_memory" {
type = number
default = 4096
}
variable "engine_count" {
type = number
default = 1
}
variable "enable_ai_engine" {
type = bool
default = false
}
variable "db_password" {
type = string
sensitive = true
}
variable "engine_shared_secret" {
type = string
sensitive = true
}
variable "valkey_auth_token" {
# ElastiCache AUTH token (16-128 chars). Generate: openssl rand -hex 32
type = string
sensitive = true
validation {
condition = length(var.valkey_auth_token) >= 16 && length(var.valkey_auth_token) <= 128
error_message = "valkey_auth_token must be between 16 and 128 characters."
}
}
variable "vpc_cidr" {
type = string
default = "10.42.0.0/16"
}
# ----- networking (slim VPC, two AZs) -----
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.name}-vpc"
}
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_subnet" "a" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1)
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
}
resource "aws_subnet" "b" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 2)
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
}
resource "aws_route_table_association" "a" {
subnet_id = aws_subnet.a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "b" {
subnet_id = aws_subnet.b.id
route_table_id = aws_route_table.public.id
}
# ----- security groups -----
resource "aws_security_group" "alb" {
name = "${var.name}-alb"
description = "Public ALB"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "app" {
name = "${var.name}-app"
description = "Stirling app tasks"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group_rule" "alb_to_app" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_group_id = aws_security_group.app.id
source_security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "app_to_app" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_group_id = aws_security_group.app.id
source_security_group_id = aws_security_group.app.id
}
resource "aws_security_group" "valkey" {
name = "${var.name}-valkey"
description = "Valkey ElastiCache"
vpc_id = aws_vpc.main.id
ingress {
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
}
resource "aws_security_group" "db" {
name = "${var.name}-db"
description = "PostgreSQL"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
}
resource "aws_security_group" "engine_lb" {
count = var.enable_ai_engine ? 1 : 0
name = "${var.name}-engine-lb"
description = "Internal ALB in front of engine tier - app tasks only"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5001
to_port = 5001
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "engine" {
count = var.enable_ai_engine ? 1 : 0
name = "${var.name}-engine"
description = "AI engine tasks - only reachable from the internal engine LB"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5001
to_port = 5001
protocol = "tcp"
security_groups = [aws_security_group.engine_lb[0].id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# ----- managed Valkey (ElastiCache for Valkey, GA since 2024) -----
resource "aws_elasticache_subnet_group" "valkey" {
name = "${var.name}-valkey"
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
}
resource "aws_elasticache_replication_group" "valkey" {
replication_group_id = "${var.name}-valkey"
description = "Stirling Valkey"
engine = "valkey"
engine_version = "8.0"
node_type = "cache.t4g.small"
num_cache_clusters = 1
automatic_failover_enabled = false
subnet_group_name = aws_elasticache_subnet_group.valkey.name
security_group_ids = [aws_security_group.valkey.id]
at_rest_encryption_enabled = true
# TLS in flight required when auth_token is set (ElastiCache enforces this).
transit_encryption_enabled = true
auth_token = var.valkey_auth_token
}
# ----- managed Postgres -----
resource "aws_db_subnet_group" "pg" {
name = "${var.name}-pg"
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
}
resource "aws_db_instance" "pg" {
identifier = "${var.name}-pg"
engine = "postgres"
engine_version = "17.2"
instance_class = "db.t4g.micro"
allocated_storage = 20
username = "stirling"
password = var.db_password
db_name = "stirling"
db_subnet_group_name = aws_db_subnet_group.pg.name
vpc_security_group_ids = [aws_security_group.db.id]
storage_encrypted = true
skip_final_snapshot = false
final_snapshot_identifier = "${var.name}-pg-final"
backup_retention_period = 7
}
# ----- secrets -----
resource "aws_secretsmanager_secret" "bundle" {
name = "${var.name}-secrets"
}
resource "aws_secretsmanager_secret_version" "bundle" {
secret_id = aws_secretsmanager_secret.bundle.id
secret_string = jsonencode({
engineSharedSecret = var.engine_shared_secret
dbPassword = var.db_password
valkeyAuthToken = var.valkey_auth_token
})
}
# ----- ECS cluster + IAM -----
resource "aws_ecs_cluster" "main" {
name = "${var.name}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_iam_role" "exec" {
name = "${var.name}-exec"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "exec_managed" {
role = aws_iam_role.exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "exec_read_secrets" {
role = aws_iam_role.exec.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = aws_secretsmanager_secret.bundle.arn
}]
})
}
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/${var.name}"
retention_in_days = 14
}
# ----- ALB + target group + listener (blocks /internal/*) -----
resource "aws_lb" "alb" {
name = "${var.name}-alb"
load_balancer_type = "application"
subnets = [aws_subnet.a.id, aws_subnet.b.id]
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_target_group" "app" {
name = "${var.name}-app-tg"
vpc_id = aws_vpc.main.id
port = 8080
protocol = "HTTP"
target_type = "ip"
health_check {
path = "/api/v1/info/status"
matcher = "200"
interval = 30
timeout = 10
healthy_threshold = 2
unhealthy_threshold = 5
}
# Sticky sessions required - see deploy/aws/README.md.
stickiness {
type = "lb_cookie"
cookie_duration = 86400
enabled = true
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.alb.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
resource "aws_lb_listener_rule" "block_internal" {
listener_arn = aws_lb_listener.http.arn
priority = 1
condition {
path_pattern {
values = ["/internal/*"]
}
}
action {
type = "fixed-response"
fixed_response {
status_code = "404"
content_type = "text/plain"
message_body = "Not Found"
}
}
}
# ----- app task definition + service -----
resource "aws_ecs_task_definition" "app" {
family = "${var.name}-app"
cpu = var.app_cpu
memory = var.app_memory
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
execution_role_arn = aws_iam_role.exec.arn
container_definitions = jsonencode([{
name = "stirling"
image = var.app_image
essential = true
portMappings = [{
containerPort = 8080
protocol = "tcp"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.ecs.name
awslogs-region = var.region
awslogs-stream-prefix = "app"
}
}
environment = concat([
{ name = "CLUSTER_ENABLED", value = "true" },
{ name = "CLUSTER_BACKPLANE", value = "valkey" },
# rediss:// = TLS. REDIS_PASSWORD injected separately via secrets below.
{ name = "CLUSTER_VALKEY_URL", value = "rediss://${aws_elasticache_replication_group.valkey.primary_endpoint_address}:6379" },
{ name = "SPRING_DATASOURCE_URL", value = "jdbc:postgresql://${aws_db_instance.pg.endpoint}/stirling" },
{ name = "SPRING_DATASOURCE_USERNAME", value = "stirling" },
{ name = "DOCKER_ENABLE_SECURITY", value = "true" },
],
var.enable_ai_engine ? [
{ name = "AIENGINE_URL", value = "http://${aws_lb.engine[0].dns_name}:5001" },
] : []
)
secrets = [
{ name = "CLUSTER_ENGINE_SHAREDSECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
{ name = "SPRING_DATASOURCE_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:dbPassword::" },
{ name = "REDIS_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:valkeyAuthToken::" },
]
}])
}
resource "aws_ecs_service" "app" {
name = "${var.name}-app"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.app_count
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 50
deployment_maximum_percent = 200
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
health_check_grace_period_seconds = 120
network_configuration {
subnets = [aws_subnet.a.id, aws_subnet.b.id]
security_groups = [aws_security_group.app.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "stirling"
container_port = 8080
}
depends_on = [aws_lb_listener.http]
}
# ----- autoscaling -----
resource "aws_appautoscaling_target" "app" {
max_capacity = 10
min_capacity = var.app_count
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${var.name}-cpu-target"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.app.resource_id
scalable_dimension = aws_appautoscaling_target.app.scalable_dimension
service_namespace = aws_appautoscaling_target.app.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 70
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
scale_in_cooldown = 60
scale_out_cooldown = 60
}
}
# ----- AI engine tier (internal LB + task def + service) -----
resource "aws_lb" "engine" {
count = var.enable_ai_engine ? 1 : 0
name = "${var.name}-engine-alb"
internal = true
load_balancer_type = "application"
subnets = [aws_subnet.a.id, aws_subnet.b.id]
security_groups = [aws_security_group.engine_lb[0].id]
}
resource "aws_lb_target_group" "engine" {
count = var.enable_ai_engine ? 1 : 0
name = "${var.name}-engine-tg"
vpc_id = aws_vpc.main.id
port = 5001
protocol = "HTTP"
target_type = "ip"
health_check {
path = "/health"
matcher = "200"
interval = 30
timeout = 10
healthy_threshold = 2
unhealthy_threshold = 5
}
}
resource "aws_lb_listener" "engine" {
count = var.enable_ai_engine ? 1 : 0
load_balancer_arn = aws_lb.engine[0].arn
port = 5001
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.engine[0].arn
}
}
resource "aws_ecs_task_definition" "engine" {
count = var.enable_ai_engine ? 1 : 0
family = "${var.name}-engine"
cpu = 1024
memory = 2048
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
execution_role_arn = aws_iam_role.exec.arn
container_definitions = jsonencode([{
name = "engine"
image = var.engine_image
essential = true
portMappings = [{
containerPort = 5001
protocol = "tcp"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.ecs.name
awslogs-region = var.region
awslogs-stream-prefix = "engine"
}
}
secrets = [
{ name = "STIRLING_ENGINE_SHARED_SECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
]
}])
}
resource "aws_ecs_service" "engine" {
count = var.enable_ai_engine ? 1 : 0
name = "${var.name}-engine"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.engine[0].arn
desired_count = var.engine_count
launch_type = "FARGATE"
# Engine model-load warm-up can take 60-90s.
health_check_grace_period_seconds = 120
network_configuration {
subnets = [aws_subnet.a.id, aws_subnet.b.id]
security_groups = [aws_security_group.engine[0].id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.engine[0].arn
container_name = "engine"
container_port = 5001
}
depends_on = [aws_lb_listener.engine]
}
# ----- outputs -----
output "app_url" {
value = "http://${aws_lb.alb.dns_name}/"
}
output "valkey_endpoint" {
value = aws_elasticache_replication_group.valkey.primary_endpoint_address
}
output "postgres_endpoint" {
value = aws_db_instance.pg.endpoint
}
output "cluster_name" {
value = aws_ecs_cluster.main.name
}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: stirling-pdf
description: Stirling-PDF clustered deployment (web + worker + AI engine + Valkey).
type: application
version: 0.1.0
appVersion: "2.11.0"
@@ -0,0 +1,44 @@
Stirling-PDF release "{{ .Release.Name }}" has been deployed.
{{- if .Values.ingress.enabled }}
Front door: https://{{ .Values.ingress.host }}/ (via Ingress class "{{ .Values.ingress.className }}")
{{- if not .Values.ingress.tls.enabled }}
WARNING: TLS is disabled on the Ingress (ingress.tls.enabled=false).
Cookies, JWTs, and API keys will cross the wire in plaintext. Acceptable
for dev / loopback only. Re-enable TLS and provide a secret named
"{{ .Values.ingress.tls.secretName }}" (cert-manager or kubectl create secret tls).
{{- end }}
{{- end }}
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
Production deployments MUST use an external HA Valkey/Redis: set
cluster.valkey.bundled=false
cluster.valkey.externalUrl=rediss://<managed-endpoint>:6379
Managed options: AWS ElastiCache for Valkey, GCP Memorystore, the Valkey
Operator with multi-replica + Sentinel, or any other HA Redis-protocol service.
See deploy/aws/README.md for the recommended managed paths.
{{- end }}
{{- if and .Values.cluster.enabled (not .Values.cluster.engineSharedSecret) }}
INFO: cluster.engineSharedSecret was auto-generated and stored in the Secret
"{{ .Release.Name }}-cluster-secrets". It is stable across `helm upgrade`
(the chart reads the existing Secret on each render) but will regenerate on
`helm uninstall` + reinstall. To pin it, retrieve with:
kubectl get secret {{ .Release.Name }}-cluster-secrets \
-o jsonpath='{.data.engineSharedSecret}' | base64 -d
and store it in your secret manager / values file for future installs.
{{- end }}
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled (not .Values.cluster.valkey.password) }}
INFO: cluster.valkey.password was auto-generated for the bundled Valkey and
stored in "{{ .Release.Name }}-cluster-secrets". Stable across upgrades but
regenerates on uninstall + reinstall (which would orphan the persisted
Valkey PVC). Pin it explicitly before going beyond eval/dev:
kubectl get secret {{ .Release.Name }}-cluster-secrets \
-o jsonpath='{.data.valkeyPassword}' | base64 -d
{{- end }}
@@ -0,0 +1,13 @@
{{/* Resolve the Valkey URL - bundled vs. external bring-your-own.
Bundled path uses ${REDIS_PASSWORD} (shell expansion at Spring Boot startup) so the
password is never written into the rendered manifest or K8s events.
*/}}
{{- define "stirling-pdf.valkeyUrl" -}}
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled -}}
redis://:${REDIS_PASSWORD}@{{ .Release.Name }}-valkey:6379
{{- else if .Values.cluster.valkey.externalUrl -}}
{{ .Values.cluster.valkey.externalUrl }}
{{- else if .Values.cluster.enabled -}}
{{ fail "cluster.valkey.bundled=false requires cluster.valkey.externalUrl to be set (e.g. rediss://user:pw@host:6379)" }}
{{- end -}}
{{- end -}}
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-engine
spec:
replicas: {{ .Values.engine.replicas }}
selector:
matchLabels:
app: stirling-pdf
role: engine
template:
metadata:
labels:
app: stirling-pdf
role: engine
spec:
containers:
- name: engine
image: {{ .Values.engine.image.repository }}:{{ .Values.engine.image.tag | default .Chart.AppVersion }}
env:
- name: STIRLING_ENGINE_SHARED_SECRET
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cluster-secrets
key: engineSharedSecret
ports:
- containerPort: 5001
# Startup probe: 30 x 5s = 150s grace for model-load warm-up. Requires k8s >= 1.18.
startupProbe:
httpGet:
path: /health
port: 5001
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /health
port: 5001
periodSeconds: 10
failureThreshold: 10
livenessProbe:
httpGet:
path: /health
port: 5001
periodSeconds: 10
failureThreshold: 10
{{- with .Values.engine.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-engine
spec:
selector:
app: stirling-pdf
role: engine
ports:
- port: 5001
targetPort: 5001
@@ -0,0 +1,41 @@
{{- if .Values.web.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ .Release.Name }}-web
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ .Release.Name }}-web
minReplicas: {{ .Values.web.autoscaling.minReplicas }}
maxReplicas: {{ .Values.web.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.web.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
---
{{- if .Values.worker.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ .Release.Name }}-worker
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ .Release.Name }}-worker
minReplicas: {{ .Values.worker.autoscaling.minReplicas }}
maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
@@ -0,0 +1,45 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
annotations:
# Cookie affinity is required - see deploy/aws/README.md "Sticky sessions are required".
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
nginx.ingress.kubernetes.io/session-cookie-name: "STIRLING_NODE"
nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
# Match the JVM-side 2000MB multipart limit (nginx-ingress default 1MB would 413).
nginx.ingress.kubernetes.io/proxy-body-size: "2000m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
# Defense-in-depth: /internal/* is never a public route.
nginx.ingress.kubernetes.io/server-snippet: |
location ^~ /internal/ { return 404; }
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls.enabled }}
{{- if not .Values.ingress.tls.secretName }}
{{- fail "ingress.tls.enabled=true requires ingress.tls.secretName to be set" }}
{{- end }}
tls:
- hosts:
{{- if .Values.ingress.tls.hosts }}
{{- toYaml .Values.ingress.tls.hosts | nindent 8 }}
{{- else }}
- {{ .Values.ingress.host }}
{{- end }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-web
port:
number: 8080
{{- end }}
@@ -0,0 +1,59 @@
{{/*
Stable-secret pattern. Resolution order for each field:
1. Operator-provided value (.Values.*) wins.
2. Otherwise reuse what is already in the cluster Secret (lookup), so
`helm upgrade` does NOT churn the value on every run.
3. Otherwise generate a fresh 64-char random value (first install).
Caveats:
- `lookup` returns empty during `helm template` / `--dry-run=client`. That
is acceptable here: those modes are for inspection, not source of truth.
Real `helm install` / `helm upgrade` against a live API server see the
existing Secret.
- `helm uninstall` removes this Secret. A subsequent `helm install` with no
operator override will generate fresh values, and any persisted data tied
to the old Valkey password (PVC contents) will be unreadable.
*/}}
{{- $secretName := printf "%s-cluster-secrets" .Release.Name }}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
{{- $existingEngine := "" }}
{{- $existingValkey := "" }}
{{- if $existing }}
{{- if hasKey $existing.data "engineSharedSecret" }}
{{- $existingEngine = index $existing.data "engineSharedSecret" | b64dec }}
{{- end }}
{{- if hasKey $existing.data "valkeyPassword" }}
{{- $existingValkey = index $existing.data "valkeyPassword" | b64dec }}
{{- end }}
{{- end }}
{{- $engineSecret := "" }}
{{- if .Values.cluster.engineSharedSecret }}
{{- $engineSecret = .Values.cluster.engineSharedSecret }}
{{- else if $existingEngine }}
{{- $engineSecret = $existingEngine }}
{{- else }}
{{- $engineSecret = randAlphaNum 64 }}
{{- end }}
{{- $needsBundledValkey := and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
{{- $valkeyPassword := "" }}
{{- if $needsBundledValkey }}
{{- if .Values.cluster.valkey.password }}
{{- $valkeyPassword = .Values.cluster.valkey.password }}
{{- else if $existingValkey }}
{{- $valkeyPassword = $existingValkey }}
{{- else }}
{{- $valkeyPassword = randAlphaNum 64 }}
{{- end }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
type: Opaque
stringData:
engineSharedSecret: {{ $engineSecret | quote }}
{{- if $needsBundledValkey }}
valkeyPassword: {{ $valkeyPassword | quote }}
{{- end }}
@@ -0,0 +1,80 @@
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-valkey
spec:
serviceName: {{ .Release.Name }}-valkey
replicas: 1
selector:
matchLabels:
app: stirling-valkey
template:
metadata:
labels:
app: stirling-valkey
spec:
containers:
- name: valkey
image: valkey/valkey:8.0-alpine
command:
- sh
- -c
- "exec valkey-server --requirepass \"$REDIS_PASSWORD\" --maxmemory 256mb --maxmemory-policy allkeys-lru"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cluster-secrets
key: valkeyPassword
ports:
- containerPort: 6379
readinessProbe:
exec:
command:
- sh
- -c
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
exec:
command:
- sh
- -c
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
initialDelaySeconds: 15
periodSeconds: 10
resources:
{{- toYaml .Values.cluster.valkey.resources | nindent 12 }}
{{- if .Values.cluster.valkey.persistence.enabled }}
volumeMounts:
- name: data
mountPath: /data
{{- end }}
{{- if .Values.cluster.valkey.persistence.enabled }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.cluster.valkey.persistence.storageClassName }}
storageClassName: {{ .Values.cluster.valkey.persistence.storageClassName }}
{{- end }}
resources:
requests:
storage: {{ .Values.cluster.valkey.persistence.size }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-valkey
spec:
type: ClusterIP
selector:
app: stirling-valkey
ports:
- port: 6379
targetPort: 6379
{{- end }}
@@ -0,0 +1,75 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-web
labels:
app: stirling-pdf
role: web
spec:
replicas: {{ .Values.web.replicas }}
selector:
matchLabels:
app: stirling-pdf
role: web
template:
metadata:
labels:
app: stirling-pdf
role: web
spec:
containers:
- name: app
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: MODE
value: FRONTEND
- name: CLUSTER_ENABLED
value: {{ .Values.cluster.enabled | quote }}
- name: CLUSTER_BACKPLANE
value: {{ .Values.cluster.backplane }}
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cluster-secrets
key: valkeyPassword
{{- end }}
- name: CLUSTER_VALKEY_URL
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
- name: CLUSTER_NODE_ROLE
value: web
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: CLUSTER_ENGINE_SHAREDSECRET
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cluster-secrets
key: engineSharedSecret
ports:
- containerPort: 8080
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
startupProbe:
httpGet:
path: /api/v1/info/status
port: 8080
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /api/v1/info/status
port: 8080
periodSeconds: 10
failureThreshold: 10
livenessProbe:
httpGet:
path: /api/v1/info/status
port: 8080
periodSeconds: 10
failureThreshold: 10
{{- with .Values.web.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-web
spec:
selector:
app: stirling-pdf
role: web
ports:
- port: 8080
targetPort: 8080
@@ -0,0 +1,75 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-worker
labels:
app: stirling-pdf
role: worker
spec:
replicas: {{ .Values.worker.replicas }}
selector:
matchLabels:
app: stirling-pdf
role: worker
template:
metadata:
labels:
app: stirling-pdf
role: worker
spec:
containers:
- name: app
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: MODE
value: BACKEND
- name: CLUSTER_ENABLED
value: {{ .Values.cluster.enabled | quote }}
- name: CLUSTER_BACKPLANE
value: {{ .Values.cluster.backplane }}
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cluster-secrets
key: valkeyPassword
{{- end }}
- name: CLUSTER_VALKEY_URL
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
- name: CLUSTER_NODE_ROLE
value: worker
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: CLUSTER_ENGINE_SHAREDSECRET
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-cluster-secrets
key: engineSharedSecret
ports:
- containerPort: 8080
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
startupProbe:
httpGet:
path: /api/v1/info/status
port: 8080
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /api/v1/info/status
port: 8080
periodSeconds: 10
failureThreshold: 10
livenessProbe:
httpGet:
path: /api/v1/info/status
port: 8080
periodSeconds: 10
failureThreshold: 10
{{- with .Values.worker.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
+91
View File
@@ -0,0 +1,91 @@
# Stirling-PDF Helm chart - clustered install (web + worker + AI engine + Valkey behind Ingress).
# Single-instance deploys can run the standard container directly without this chart.
image:
repository: stirlingtools/stirling-pdf
# Leave blank to default to .Chart.AppVersion. Override per release.
tag: ""
pullPolicy: IfNotPresent
engine:
image:
repository: stirlingtools/stirling-pdf-ai-engine
tag: ""
replicas: 2
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
web:
replicas: 2
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
worker:
replicas: 2
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
cluster:
enabled: true
backplane: valkey
valkey:
# WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
# Production: set bundled=false and point externalUrl at HA Valkey/Redis.
# See deploy/aws/README.md for managed options (ElastiCache, Memorystore, etc.).
bundled: true
# Required when bundled=false. Use rediss:// for TLS.
externalUrl: ""
# Auto-generated on first install if empty (stable across upgrades via Secret lookup).
# Set explicitly via --set or values file when using your own secret manager.
password: ""
persistence:
enabled: true
size: 1Gi
storageClassName: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 384Mi
# Java <-> AI engine auth secret. Auto-generated if empty (see NOTES.txt for retrieval).
engineSharedSecret: ""
ingress:
enabled: true
host: stirling.example.com
className: nginx
tls:
# WARNING: TLS MUST be on in production - cookies, JWTs, and API keys cross the wire.
# Provision the secret below (cert-manager, kubectl create secret tls, etc.) before helm install.
# tls.enabled=false is HTTP-only - dev/loopback ONLY.
enabled: true
secretName: stirling-pdf-tls
# Defaults to [ingress.host] when empty.
hosts: []
@@ -0,0 +1,175 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Starter dashboard for Stirling-PDF cluster mode (plan §7). Add to your Grafana instance pointed at a Prometheus that is scraping /actuator/prometheus on each node. NOTE: requires PR3 (Valkey backplane impls + ClusterMetrics) - panels render 'No data' until that lands.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
"id": 1,
"options": {"legend": {"displayMode": "table", "showLegend": true}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "stirling_cluster_jobs_inflight",
"legendFormat": "{{node}}",
"refId": "A"
}
],
"title": "Jobs in flight (per node)",
"type": "timeseries",
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
"id": 2,
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "stirling_cluster_queue_depth",
"legendFormat": "{{lane}}",
"refId": "A"
}
],
"title": "Queue depth (per lane)",
"type": "timeseries",
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"id": 3,
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "B"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "histogram_quantile(0.99, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "Job wait time (p50/p95/p99)",
"type": "timeseries",
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
"id": 4,
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p95",
"refId": "B"
}
],
"title": "Backplane round-trip latency (Valkey p50/p95)",
"type": "timeseries",
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
"id": 5,
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "sum(rate(stirling_cluster_sticky_miss_total[5m]))",
"legendFormat": "sticky-session misses / sec",
"refId": "A"
}
],
"title": "Sticky-session miss rate (high = LB affinity broken)",
"type": "timeseries",
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
},
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
"id": 6,
"targets": [
{
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"expr": "sum(rate(stirling_cluster_ratelimit_rejected_total[5m]))",
"legendFormat": "rejections / sec",
"refId": "A"
}
],
"title": "Rate-limit rejections (cluster-wide)",
"type": "timeseries",
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["stirling-pdf", "cluster"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Stirling-PDF Cluster",
"uid": "stirling-cluster-phase1",
"version": 1,
"weekStart": ""
}
+95
View File
@@ -0,0 +1,95 @@
# Stirling-PDF unified container. MODE env var selects role: FRONTEND, BACKEND, or BOTH.
ARG BASE_VERSION=1.0.2
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application
FROM gradle:9.3.1-jdk25 AS app-build
ARG TASK_VERSION=3.49.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& update-ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& ARCH=$(dpkg --print-architecture) \
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
&& dpkg -i /tmp/task.deb \
&& rm /tmp/task.deb \
&& rm -rf /var/lib/apt/lists/*
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
WORKDIR /app
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
COPY app/core/build.gradle app/core/
COPY app/common/build.gradle app/common/
COPY app/proprietary/build.gradle app/proprietary/
COPY . .
ARG STIRLING_FLAVOR=proprietary
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
gradle clean build \
-PbuildWithFrontend=true \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Stage 2: Extract Spring Boot layers
FROM eclipse-temurin:25-jre-noble AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Stage 3: Runtime
FROM ${BASE_IMAGE}
ARG VERSION_TAG
WORKDIR /app
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
COPY --link --chown=1000:1000 scripts/ /scripts/
ENV MODE=BOTH \
BACKEND_INTERNAL_PORT=8081 \
STIRLING_AOT_ENABLE="false" \
STIRLING_JVM_PROFILE="balanced" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf
RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
LABEL org.opencontainers.image.title="Stirling-PDF Unified" \
org.opencontainers.image.description="Unified container - selectable role via MODE env var." \
org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.vendor="Stirling-Tools" \
org.opencontainers.image.version="${VERSION_TAG}"
EXPOSE 8080/tcp
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
# Map MODE to cluster node role at container start.
ENTRYPOINT ["sh", "-c", "case \"$MODE\" in BACKEND) export CLUSTER_NODE_ROLE=worker;; FRONTEND) export CLUSTER_NODE_ROLE=web;; *) export CLUSTER_NODE_ROLE=both;; esac; exec tini -- /scripts/init.sh"]
CMD []
+194
View File
@@ -0,0 +1,194 @@
# Reference clustered deployment: load balancer + N app + N engine + Valkey + Postgres.
# Non-k8s enterprise self-host blueprint. /internal/* stays off the LB.
#
# Required env vars (set in a .env beside this file or via your secret store):
# CLUSTER_ENGINE_SHAREDSECRET - generate: openssl rand -hex 32
# STIRLING_VALKEY_PASSWORD - generate: openssl rand -hex 32
# POSTGRES_PASSWORD - required
# STIRLING_PREMIUM_KEY - SERVER/ENTERPRISE license key (cluster mode is a paid feature)
services:
valkey:
image: valkey/valkey:8.0-alpine
container_name: stirling-valkey
restart: unless-stopped
command:
[
"valkey-server",
"--requirepass",
"${STIRLING_VALKEY_PASSWORD:?STIRLING_VALKEY_PASSWORD must be set in production}",
"--maxmemory",
"256mb",
"--maxmemory-policy",
"allkeys-lru"
]
healthcheck:
test:
[
"CMD",
"valkey-cli",
"-a",
"${STIRLING_VALKEY_PASSWORD:?required}",
"--no-auth-warning",
"ping"
]
interval: 5s
timeout: 3s
retries: 5
# Do not publish 6379 in prod. Uncomment only for local valkey-cli introspection.
# ports:
# - "6379:6379"
deploy:
resources:
limits:
memory: 384m
reservations:
memory: 128m
volumes:
- valkey-data:/data
postgres:
image: postgres:17-alpine
container_name: stirling-postgres
environment:
POSTGRES_USER: stirling
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
POSTGRES_DB: stirling
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
app-1:
build:
context: ../..
dockerfile: docker/Dockerfile.unified
container_name: stirling-app-1
restart: unless-stopped
environment:
MODE: BOTH
CLUSTER_ENABLED: "true"
CLUSTER_BACKPLANE: valkey
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
CLUSTER_NODE_ID: app-1
CLUSTER_NODE_INTERNALADDRESS: app-1:8080
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
AIENGINE_URL: http://engine-lb:5001
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
SPRING_DATASOURCE_USERNAME: stirling
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_started
engine-lb:
condition: service_started
deploy:
resources:
limits:
memory: 2g
reservations:
memory: 512m
app-2:
build:
context: ../..
dockerfile: docker/Dockerfile.unified
container_name: stirling-app-2
restart: unless-stopped
environment:
MODE: BOTH
CLUSTER_ENABLED: "true"
CLUSTER_BACKPLANE: valkey
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
CLUSTER_NODE_ID: app-2
CLUSTER_NODE_INTERNALADDRESS: app-2:8080
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
AIENGINE_URL: http://engine-lb:5001
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
SPRING_DATASOURCE_USERNAME: stirling
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
depends_on:
valkey:
condition: service_healthy
postgres:
condition: service_started
engine-lb:
condition: service_started
deploy:
resources:
limits:
memory: 2g
reservations:
memory: 512m
engine-1:
build:
context: ../../engine
dockerfile: Dockerfile.dev
container_name: stirling-engine-1
env_file: ../../engine/.env
environment:
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
engine-2:
build:
context: ../../engine
dockerfile: Dockerfile.dev
container_name: stirling-engine-2
env_file: ../../engine/.env
environment:
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
# Internal round-robin LB for the engine tier - not exposed to the host.
engine-lb:
image: nginx:1.27-alpine
container_name: stirling-engine-lb
volumes:
- ./engine-nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- engine-1
- engine-2
deploy:
resources:
limits:
memory: 64m
lb:
image: nginx:1.27-alpine
container_name: stirling-lb
ports:
- "8080:8080"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app-1
- app-2
deploy:
resources:
limits:
memory: 128m
volumes:
valkey-data:
postgres-data:
+27
View File
@@ -0,0 +1,27 @@
# nginx LB for the AI engine tier of docker-compose-cluster.yml.
# Round-robin is safe (engine is stateless). The separate LB hop exists because the JVM
# caches DNS lookups for the process lifetime, so compose round-robin DNS would pin each
# JVM to one engine and defeat scaling.
events { worker_connections 1024; }
http {
upstream stirling_engine {
server engine-1:5001;
server engine-2:5001;
}
server {
listen 5001;
# Matches JVM multipart cap; nginx default 1MB would 413.
client_max_body_size 2000m;
location / {
proxy_pass http://stirling_engine;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
# nginx LB for docker-compose-cluster.yml.
# Sends all traffic to the Java app (including /api/v1/ai/*, which the app proxies to
# the engine with X-Engine-Auth attestation). /internal/* is 404'd. The engine tier
# is never exposed directly to clients.
events { worker_connections 1024; }
http {
upstream stirling_app {
# Sticky sessions required - see deploy/aws/README.md for ip_hash caveat + alternatives.
ip_hash;
server app-1:8080;
server app-2:8080;
}
server {
listen 8080;
# Matches JVM multipart.max-file-size; nginx default 1MB would 413.
client_max_body_size 2000m;
location /internal/ {
return 404;
}
location / {
proxy_pass http://stirling_app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
}
}
+1
View File
@@ -4,6 +4,7 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen