mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-10 08:30:39 +09:00
docs: AWS ECS Fargate deployment runbook (#3897)
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies and ships
a
> "local-first, cloud-ready" deployment model
> - The deploy docs currently cover local/Docker but not a production
> cloud target, so teams asking "how do I put this behind a real domain"
> have no canonical path
> - We already support Docker images, RDS-compatible Postgres, and an
EFS
> storage profile, so AWS ECS Fargate is a natural fit
> - Without a runbook, each team reinvents VPC, security groups, TLS,
and
> secrets wiring and usually gets at least one step wrong
> - This pull request adds `docs/deploy/aws-ecs.md`, an ECS
task-definition
> template, and an `.env.aws.example`, cross-linked from the deploy
overview
> - The benefit is a single, reproducible ~$110/mo path to a production
> deployment, plus a full teardown for throwaway environments
## What Changed
- New `docs/deploy/aws-ecs.md` — an 11-step ECS Fargate runbook covering
ECR,
VPC, RDS, EFS, Secrets Manager, IAM, ALB, and ECS service with the
deployment circuit breaker enabled
- New `docker/ecs-task-definition.json` — Fargate-ready task definition
with
`<ACCOUNT_ID>`, `<REGION>`, `<EFS_ID>`, `<DOMAIN>` placeholder tokens
- New `docker/.env.aws.example` — documents every non-secret env var the
ECS deployment needs
- `docs/deploy/overview.md` — one-line cross-reference to the new guide
- Greptile feedback addressed in follow-up commits:
- `containerName` in the service-create call now matches
`paperclip-server` in the task definition
- HTTP :80 listener added that 301-redirects to :443
- Dedicated RDS DB subnet group created before `create-db-instance`
- EFS teardown polls on mount-target deletion instead of `sleep 30`
## Verification
- Walked every step of the runbook against the task definition to
confirm
variable names (`$ALB_SG`, `$ECS_SG`, `$RDS_SG`, `$EFS_SG`, `$TG_ARN`,
`$LISTENER_ARN`, `$HTTP_LISTENER_ARN`, `$EFS_ID`, `$RDS_ENDPOINT`, etc.)
are
defined before they are referenced
- Confirmed the `containerName` in Step 10 (`paperclip-server`) matches
`docker/ecs-task-definition.json` line 11
- Confirmed the `sed` placeholder substitution in Step 8 matches the
tokens
in the task definition template
- Teardown order was checked in reverse-dependency order: ECS service →
listeners → target group → ALB → RDS (waits for deletion) → DB subnet
group → EFS mount targets (polled) → EFS → secrets → SGs → ECR → IAM →
log group
## Risks
- **Low risk for the repo.** Docs-only change plus two template files
under
`docker/`; no runtime code paths are touched and nothing is imported by
the build.
- **Risk for users who follow the runbook:** AWS bills accrue
immediately
once RDS/ALB/EFS exist. The runbook calls this out and includes a full
teardown procedure. Placeholder tokens (`<ACCOUNT_ID>`, `<REGION>`,
`<EFS_ID>`, `<DOMAIN>`) are documented so nothing is silently
hard-coded.
## Model Used
- Claude (Anthropic), model `claude-opus-4-6`, ~200K context window,
extended thinking mode on, used with tool access (file edit, shell) via
Claude Code. The Greptile follow-up commits were authored the same way.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass — N/A for docs/config
templates; validated by reading
- [x] I have added or updated tests where applicable — N/A for docs
- [x] If this change affects the UI, I have included before/after
screenshots — N/A, no UI
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1d8c7a09b8
commit
f0f9460d1d
4 changed files with 701 additions and 1 deletions
30
docker/.env.aws.example
Normal file
30
docker/.env.aws.example
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# AWS ECS Fargate deployment environment
|
||||
# Copy to .env.aws and fill in values before deploying
|
||||
#
|
||||
# Secrets (DATABASE_URL, BETTER_AUTH_SECRET, ANTHROPIC_API_KEY, OPENAI_API_KEY,
|
||||
# GITHUB_TOKEN) are injected via AWS Secrets Manager — do NOT set them here.
|
||||
|
||||
# Deployment mode
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=public
|
||||
PAPERCLIP_PUBLIC_URL=https://paperclip.example.com
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=3100
|
||||
NODE_ENV=production
|
||||
SERVE_UI=true
|
||||
|
||||
# Paperclip paths
|
||||
PAPERCLIP_HOME=/paperclip
|
||||
PAPERCLIP_INSTANCE_ID=default
|
||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json
|
||||
|
||||
# Auto-apply migrations on startup
|
||||
PAPERCLIP_MIGRATION_AUTO_APPLY=true
|
||||
|
||||
# Enable heartbeat scheduler for remote agents
|
||||
HEARTBEAT_SCHEDULER_ENABLED=true
|
||||
|
||||
# Post-deploy hardening (uncomment after first user signs up)
|
||||
# PAPERCLIP_AUTH_DISABLE_SIGN_UP=true
|
||||
90
docker/ecs-task-definition.json
Normal file
90
docker/ecs-task-definition.json
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"family": "paperclip-server",
|
||||
"networkMode": "awsvpc",
|
||||
"requiresCompatibilities": ["FARGATE"],
|
||||
"cpu": "2048",
|
||||
"memory": "4096",
|
||||
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/paperclip-ecs-execution",
|
||||
"taskRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/paperclip-ecs-task",
|
||||
"containerDefinitions": [
|
||||
{
|
||||
"name": "paperclip-server",
|
||||
"image": "<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/paperclip-server:latest",
|
||||
"essential": true,
|
||||
"portMappings": [
|
||||
{
|
||||
"containerPort": 3100,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
{ "name": "NODE_ENV", "value": "production" },
|
||||
{ "name": "HOST", "value": "0.0.0.0" },
|
||||
{ "name": "PORT", "value": "3100" },
|
||||
{ "name": "SERVE_UI", "value": "true" },
|
||||
{ "name": "PAPERCLIP_HOME", "value": "/paperclip" },
|
||||
{ "name": "PAPERCLIP_INSTANCE_ID", "value": "default" },
|
||||
{ "name": "PAPERCLIP_CONFIG", "value": "/paperclip/instances/default/config.json" },
|
||||
{ "name": "PAPERCLIP_DEPLOYMENT_MODE", "value": "authenticated" },
|
||||
{ "name": "PAPERCLIP_DEPLOYMENT_EXPOSURE", "value": "public" },
|
||||
{ "name": "PAPERCLIP_PUBLIC_URL", "value": "https://<DOMAIN>" },
|
||||
{ "name": "PAPERCLIP_MIGRATION_AUTO_APPLY", "value": "true" },
|
||||
{ "name": "HEARTBEAT_SCHEDULER_ENABLED", "value": "true" }
|
||||
],
|
||||
"secrets": [
|
||||
{
|
||||
"name": "DATABASE_URL",
|
||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/database-url"
|
||||
},
|
||||
{
|
||||
"name": "BETTER_AUTH_SECRET",
|
||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/better-auth-secret"
|
||||
},
|
||||
{
|
||||
"name": "ANTHROPIC_API_KEY",
|
||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/anthropic-api-key"
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/openai-api-key"
|
||||
},
|
||||
{
|
||||
"name": "GITHUB_TOKEN",
|
||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/github-token"
|
||||
}
|
||||
],
|
||||
"mountPoints": [
|
||||
{
|
||||
"sourceVolume": "paperclip-data",
|
||||
"containerPath": "/paperclip",
|
||||
"readOnly": false
|
||||
}
|
||||
],
|
||||
"healthCheck": {
|
||||
"command": ["CMD-SHELL", "curl -f http://localhost:3100/api/health || exit 1"],
|
||||
"interval": 30,
|
||||
"timeout": 5,
|
||||
"retries": 3,
|
||||
"startPeriod": 60
|
||||
},
|
||||
"logConfiguration": {
|
||||
"logDriver": "awslogs",
|
||||
"options": {
|
||||
"awslogs-group": "/ecs/paperclip",
|
||||
"awslogs-region": "<REGION>",
|
||||
"awslogs-stream-prefix": "server"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"name": "paperclip-data",
|
||||
"efsVolumeConfiguration": {
|
||||
"fileSystemId": "<EFS_ID>",
|
||||
"rootDirectory": "/",
|
||||
"transitEncryption": "ENABLED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
580
docs/deploy/aws-ecs.md
Normal file
580
docs/deploy/aws-ecs.md
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
---
|
||||
title: AWS ECS Fargate
|
||||
summary: Deploy Paperclip to AWS using ECS Fargate, RDS Postgres, and EFS
|
||||
---
|
||||
|
||||
Deploy Paperclip to AWS with ECS Fargate (compute), RDS Postgres 17 (database), and EFS (persistent storage). This guide uses the AWS CLI and produces a single-task ECS service behind an ALB with HTTPS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AWS CLI v2 configured with a profile that has admin-level permissions
|
||||
- Docker installed locally (for building and pushing the image)
|
||||
- A registered domain with DNS you control (for the TLS certificate)
|
||||
- The Paperclip repo cloned locally
|
||||
|
||||
Set these shell variables for the rest of the guide:
|
||||
|
||||
```bash
|
||||
export AWS_REGION=us-east-1
|
||||
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
|
||||
export PAPERCLIP_DOMAIN=paperclip.example.com # your domain
|
||||
export DB_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
|
||||
export AUTH_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
## 1. Create ECR Repository
|
||||
|
||||
```bash
|
||||
aws ecr create-repository \
|
||||
--repository-name paperclip-server \
|
||||
--image-scanning-configuration scanOnPush=true \
|
||||
--region $AWS_REGION
|
||||
```
|
||||
|
||||
## 2. Build and Push Docker Image
|
||||
|
||||
```bash
|
||||
cd /path/to/paperclip
|
||||
|
||||
# Authenticate Docker to ECR
|
||||
aws ecr get-login-password --region $AWS_REGION \
|
||||
| docker login --username AWS --password-stdin \
|
||||
$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
|
||||
|
||||
# Build
|
||||
docker build -t paperclip-server .
|
||||
|
||||
# Tag and push
|
||||
docker tag paperclip-server:latest \
|
||||
$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/paperclip-server:latest
|
||||
|
||||
docker push \
|
||||
$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/paperclip-server:latest
|
||||
```
|
||||
|
||||
## 3. Networking (VPC, Subnets, Security Groups)
|
||||
|
||||
Use the default VPC or create a dedicated one. The guide assumes the default VPC with public and private subnets in two AZs.
|
||||
|
||||
```bash
|
||||
# Get default VPC
|
||||
VPC_ID=$(aws ec2 describe-vpcs \
|
||||
--filters Name=isDefault,Values=true \
|
||||
--query 'Vpcs[0].VpcId' --output text)
|
||||
|
||||
# Get two public subnets (for ALB)
|
||||
SUBNET_IDS=$(aws ec2 describe-subnets \
|
||||
--filters Name=vpc-id,Values=$VPC_ID \
|
||||
--query 'Subnets[?MapPublicIpOnLaunch==`true`] | [0:2].SubnetId' \
|
||||
--output text)
|
||||
SUBNET_1=$(echo $SUBNET_IDS | awk '{print $1}')
|
||||
SUBNET_2=$(echo $SUBNET_IDS | awk '{print $2}')
|
||||
```
|
||||
|
||||
Create security groups:
|
||||
|
||||
```bash
|
||||
# ALB security group — inbound HTTPS
|
||||
ALB_SG=$(aws ec2 create-security-group \
|
||||
--group-name paperclip-alb \
|
||||
--description "Paperclip ALB" \
|
||||
--vpc-id $VPC_ID \
|
||||
--query 'GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $ALB_SG \
|
||||
--protocol tcp --port 443 --cidr 0.0.0.0/0
|
||||
|
||||
# Also open port 80 so the ALB can accept HTTP and redirect to HTTPS
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $ALB_SG \
|
||||
--protocol tcp --port 80 --cidr 0.0.0.0/0
|
||||
|
||||
# ECS task security group — inbound from ALB only
|
||||
ECS_SG=$(aws ec2 create-security-group \
|
||||
--group-name paperclip-ecs \
|
||||
--description "Paperclip ECS tasks" \
|
||||
--vpc-id $VPC_ID \
|
||||
--query 'GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $ECS_SG \
|
||||
--protocol tcp --port 3100 \
|
||||
--source-group $ALB_SG
|
||||
|
||||
# RDS security group — inbound from ECS only
|
||||
RDS_SG=$(aws ec2 create-security-group \
|
||||
--group-name paperclip-rds \
|
||||
--description "Paperclip RDS" \
|
||||
--vpc-id $VPC_ID \
|
||||
--query 'GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $RDS_SG \
|
||||
--protocol tcp --port 5432 \
|
||||
--source-group $ECS_SG
|
||||
|
||||
# EFS security group — inbound NFS from ECS only
|
||||
EFS_SG=$(aws ec2 create-security-group \
|
||||
--group-name paperclip-efs \
|
||||
--description "Paperclip EFS" \
|
||||
--vpc-id $VPC_ID \
|
||||
--query 'GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $EFS_SG \
|
||||
--protocol tcp --port 2049 \
|
||||
--source-group $ECS_SG
|
||||
```
|
||||
|
||||
## 4. Create RDS Postgres Instance
|
||||
|
||||
```bash
|
||||
# Custom VPCs don't come with a default DB subnet group — create one
|
||||
# that spans our two subnets so RDS can place the instance.
|
||||
aws rds create-db-subnet-group \
|
||||
--db-subnet-group-name paperclip-db-subnet \
|
||||
--db-subnet-group-description "Paperclip RDS subnets" \
|
||||
--subnet-ids $SUBNET_1 $SUBNET_2
|
||||
|
||||
aws rds create-db-instance \
|
||||
--db-instance-identifier paperclip-db \
|
||||
--db-instance-class db.t4g.micro \
|
||||
--engine postgres \
|
||||
--engine-version 17 \
|
||||
--master-username paperclip \
|
||||
--master-user-password "$DB_PASSWORD" \
|
||||
--allocated-storage 20 \
|
||||
--storage-type gp3 \
|
||||
--vpc-security-group-ids $RDS_SG \
|
||||
--db-subnet-group-name paperclip-db-subnet \
|
||||
--no-publicly-accessible \
|
||||
--backup-retention-period 7 \
|
||||
--no-multi-az \
|
||||
--db-name paperclip \
|
||||
--region $AWS_REGION
|
||||
|
||||
# Wait for it to become available (takes 5-10 min)
|
||||
aws rds wait db-instance-available \
|
||||
--db-instance-identifier paperclip-db
|
||||
|
||||
# Get the endpoint
|
||||
RDS_ENDPOINT=$(aws rds describe-db-instances \
|
||||
--db-instance-identifier paperclip-db \
|
||||
--query 'DBInstances[0].Endpoint.Address' --output text)
|
||||
|
||||
DATABASE_URL="postgresql://paperclip:${DB_PASSWORD}@${RDS_ENDPOINT}:5432/paperclip"
|
||||
```
|
||||
|
||||
## 5. Create EFS Filesystem
|
||||
|
||||
```bash
|
||||
EFS_ID=$(aws efs create-file-system \
|
||||
--performance-mode generalPurpose \
|
||||
--throughput-mode bursting \
|
||||
--encrypted \
|
||||
--tags Key=Name,Value=paperclip-data \
|
||||
--query 'FileSystemId' --output text)
|
||||
|
||||
# Create mount targets in each subnet
|
||||
for SUBNET in $SUBNET_1 $SUBNET_2; do
|
||||
aws efs create-mount-target \
|
||||
--file-system-id $EFS_ID \
|
||||
--subnet-id $SUBNET \
|
||||
--security-groups $EFS_SG
|
||||
done
|
||||
|
||||
# Wait for mount targets
|
||||
aws efs describe-mount-targets --file-system-id $EFS_ID
|
||||
```
|
||||
|
||||
## 6. Store Secrets
|
||||
|
||||
```bash
|
||||
aws secretsmanager create-secret \
|
||||
--name paperclip/database-url \
|
||||
--secret-string "$DATABASE_URL"
|
||||
|
||||
aws secretsmanager create-secret \
|
||||
--name paperclip/anthropic-api-key \
|
||||
--secret-string "YOUR_ANTHROPIC_KEY"
|
||||
|
||||
aws secretsmanager create-secret \
|
||||
--name paperclip/better-auth-secret \
|
||||
--secret-string "$AUTH_SECRET"
|
||||
|
||||
aws secretsmanager create-secret \
|
||||
--name paperclip/openai-api-key \
|
||||
--secret-string "YOUR_OPENAI_KEY"
|
||||
|
||||
aws secretsmanager create-secret \
|
||||
--name paperclip/github-token \
|
||||
--secret-string "YOUR_GITHUB_PAT"
|
||||
```
|
||||
|
||||
## 7. IAM Roles
|
||||
|
||||
Create the ECS task execution role (pulls images, reads secrets) and the task role (application permissions).
|
||||
|
||||
```bash
|
||||
# Task execution role
|
||||
aws iam create-role \
|
||||
--role-name paperclip-ecs-execution \
|
||||
--assume-role-policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "ecs-tasks.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole"
|
||||
}]
|
||||
}'
|
||||
|
||||
aws iam attach-role-policy \
|
||||
--role-name paperclip-ecs-execution \
|
||||
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
||||
|
||||
# Allow reading secrets
|
||||
aws iam put-role-policy \
|
||||
--role-name paperclip-ecs-execution \
|
||||
--policy-name SecretsAccess \
|
||||
--policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["secretsmanager:GetSecretValue"],
|
||||
"Resource": "arn:aws:secretsmanager:'$AWS_REGION':'$AWS_ACCOUNT_ID':secret:paperclip/*"
|
||||
}]
|
||||
}'
|
||||
|
||||
# Task role (application — add permissions as needed)
|
||||
aws iam create-role \
|
||||
--role-name paperclip-ecs-task \
|
||||
--assume-role-policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "ecs-tasks.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole"
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
## 8. ECS Cluster and Task Definition
|
||||
|
||||
```bash
|
||||
aws ecs create-cluster --cluster-name paperclip
|
||||
|
||||
aws logs create-log-group --log-group-name /ecs/paperclip
|
||||
```
|
||||
|
||||
Register the task definition using the template at `docker/ecs-task-definition.json`. Before registering, replace the placeholder values:
|
||||
|
||||
```bash
|
||||
sed -e "s|<ACCOUNT_ID>|$AWS_ACCOUNT_ID|g" \
|
||||
-e "s|<REGION>|$AWS_REGION|g" \
|
||||
-e "s|<EFS_ID>|$EFS_ID|g" \
|
||||
-e "s|<DOMAIN>|$PAPERCLIP_DOMAIN|g" \
|
||||
docker/ecs-task-definition.json > /tmp/paperclip-task-def.json
|
||||
|
||||
aws ecs register-task-definition \
|
||||
--cli-input-json file:///tmp/paperclip-task-def.json
|
||||
```
|
||||
|
||||
## 9. ALB and TLS Certificate
|
||||
|
||||
Request a certificate (you must validate via DNS):
|
||||
|
||||
```bash
|
||||
CERT_ARN=$(aws acm request-certificate \
|
||||
--domain-name $PAPERCLIP_DOMAIN \
|
||||
--validation-method DNS \
|
||||
--query 'CertificateArn' --output text)
|
||||
|
||||
# Get the CNAME record to add to your DNS
|
||||
aws acm describe-certificate \
|
||||
--certificate-arn $CERT_ARN \
|
||||
--query 'Certificate.DomainValidationOptions[0].ResourceRecord'
|
||||
```
|
||||
|
||||
Add the CNAME to your DNS provider, then wait for validation:
|
||||
|
||||
```bash
|
||||
aws acm wait certificate-validated --certificate-arn $CERT_ARN
|
||||
```
|
||||
|
||||
Create the ALB:
|
||||
|
||||
```bash
|
||||
ALB_ARN=$(aws elbv2 create-load-balancer \
|
||||
--name paperclip-alb \
|
||||
--subnets $SUBNET_1 $SUBNET_2 \
|
||||
--security-groups $ALB_SG \
|
||||
--scheme internet-facing \
|
||||
--type application \
|
||||
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
|
||||
|
||||
ALB_DNS=$(aws elbv2 describe-load-balancers \
|
||||
--load-balancer-arns $ALB_ARN \
|
||||
--query 'LoadBalancers[0].DNSName' --output text)
|
||||
|
||||
# Target group
|
||||
TG_ARN=$(aws elbv2 create-target-group \
|
||||
--name paperclip-tg \
|
||||
--protocol HTTP \
|
||||
--port 3100 \
|
||||
--vpc-id $VPC_ID \
|
||||
--target-type ip \
|
||||
--health-check-path /api/health \
|
||||
--health-check-interval-seconds 30 \
|
||||
--healthy-threshold-count 2 \
|
||||
--unhealthy-threshold-count 3 \
|
||||
--query 'TargetGroups[0].TargetGroupArn' --output text)
|
||||
|
||||
# HTTPS listener
|
||||
LISTENER_ARN=$(aws elbv2 create-listener \
|
||||
--load-balancer-arn $ALB_ARN \
|
||||
--protocol HTTPS \
|
||||
--port 443 \
|
||||
--certificates CertificateArn=$CERT_ARN \
|
||||
--default-actions Type=forward,TargetGroupArn=$TG_ARN \
|
||||
--query 'Listeners[0].ListenerArn' --output text)
|
||||
|
||||
# HTTP listener — redirect all :80 traffic to :443
|
||||
HTTP_LISTENER_ARN=$(aws elbv2 create-listener \
|
||||
--load-balancer-arn $ALB_ARN \
|
||||
--protocol HTTP \
|
||||
--port 80 \
|
||||
--default-actions Type=redirect,RedirectConfig='{Protocol=HTTPS,Port=443,StatusCode=HTTP_301}' \
|
||||
--query 'Listeners[0].ListenerArn' --output text)
|
||||
```
|
||||
|
||||
Point your DNS to the ALB:
|
||||
- Create a CNAME or ALIAS record for `$PAPERCLIP_DOMAIN` -> `$ALB_DNS`
|
||||
|
||||
## 10. Create ECS Service
|
||||
|
||||
```bash
|
||||
aws ecs create-service \
|
||||
--cluster paperclip \
|
||||
--service-name paperclip-server \
|
||||
--task-definition paperclip-server \
|
||||
--desired-count 1 \
|
||||
--launch-type FARGATE \
|
||||
--deployment-configuration '{
|
||||
"deploymentCircuitBreaker": {"enable": true, "rollback": true},
|
||||
"maximumPercent": 200,
|
||||
"minimumHealthyPercent": 100
|
||||
}' \
|
||||
--network-configuration '{
|
||||
"awsvpcConfiguration": {
|
||||
"subnets": ["'$SUBNET_1'", "'$SUBNET_2'"],
|
||||
"securityGroups": ["'$ECS_SG'"],
|
||||
"assignPublicIp": "ENABLED"
|
||||
}
|
||||
}' \
|
||||
--load-balancers '[{
|
||||
"targetGroupArn": "'$TG_ARN'",
|
||||
"containerName": "paperclip-server",
|
||||
"containerPort": 3100
|
||||
}]'
|
||||
```
|
||||
|
||||
> **Note:** `assignPublicIp: ENABLED` is needed if using public subnets without a NAT Gateway. For private subnets, set to `DISABLED` and ensure a NAT Gateway is configured for outbound internet access.
|
||||
|
||||
## 11. Verify Deployment
|
||||
|
||||
```bash
|
||||
# Watch task come up
|
||||
aws ecs describe-services \
|
||||
--cluster paperclip \
|
||||
--services paperclip-server \
|
||||
--query 'services[0].{desired:desiredCount,running:runningCount,status:status}'
|
||||
|
||||
# Check task health
|
||||
aws ecs list-tasks --cluster paperclip --service-name paperclip-server
|
||||
TASK_ARN=$(aws ecs list-tasks --cluster paperclip --service-name paperclip-server --query 'taskArns[0]' --output text)
|
||||
aws ecs describe-tasks --cluster paperclip --tasks $TASK_ARN \
|
||||
--query 'tasks[0].{status:lastStatus,health:healthStatus}'
|
||||
|
||||
# Check logs
|
||||
aws logs tail /ecs/paperclip --since 10m --follow
|
||||
|
||||
# Hit the health endpoint
|
||||
curl -sf https://$PAPERCLIP_DOMAIN/api/health
|
||||
```
|
||||
|
||||
**Healthy indicators:**
|
||||
- ECS task status: `RUNNING`, health: `HEALTHY`
|
||||
- Logs show `plugin job coordinator started` and `plugin-loader: loadAll complete`
|
||||
- `/api/health` returns 200
|
||||
|
||||
## Post-Deploy Security Hardening
|
||||
|
||||
After the first user has signed up (which grants admin role), lock down the instance:
|
||||
|
||||
```bash
|
||||
# Disable public sign-up (prevents unauthorized users from creating accounts)
|
||||
# Add to the task definition environment section, then redeploy:
|
||||
# { "name": "PAPERCLIP_AUTH_DISABLE_SIGN_UP", "value": "true" }
|
||||
|
||||
# Or update via Secrets Manager / task def override, then force new deployment
|
||||
aws ecs update-service \
|
||||
--cluster paperclip \
|
||||
--service paperclip-server \
|
||||
--force-new-deployment
|
||||
```
|
||||
|
||||
Use the invite flow (added in v2026.416.0) to grant access to additional users after sign-up is disabled.
|
||||
|
||||
## Deploying Updates
|
||||
|
||||
Build, push, and force a new deployment:
|
||||
|
||||
```bash
|
||||
# Build and push new image
|
||||
docker build -t paperclip-server .
|
||||
docker tag paperclip-server:latest \
|
||||
$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/paperclip-server:latest
|
||||
docker push \
|
||||
$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/paperclip-server:latest
|
||||
|
||||
# Roll out
|
||||
aws ecs update-service \
|
||||
--cluster paperclip \
|
||||
--service paperclip-server \
|
||||
--force-new-deployment
|
||||
|
||||
# Watch the deployment
|
||||
aws ecs describe-services \
|
||||
--cluster paperclip \
|
||||
--services paperclip-server \
|
||||
--query 'services[0].deployments[*].{status:status,running:runningCount,desired:desiredCount,rollout:rolloutState}'
|
||||
```
|
||||
|
||||
ECS performs a rolling update: starts a new task, waits for it to pass health checks, then drains the old task.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the new deployment is unhealthy:
|
||||
|
||||
```bash
|
||||
# ECS automatically rolls back if the new task fails health checks
|
||||
# (circuit breaker is enabled in the service configuration above).
|
||||
# To force rollback manually:
|
||||
|
||||
# 1. Find the previous task definition revision
|
||||
aws ecs list-task-definitions \
|
||||
--family-prefix paperclip-server \
|
||||
--sort DESC \
|
||||
--query 'taskDefinitionArns[0:3]'
|
||||
|
||||
# 2. Update service to the previous revision
|
||||
aws ecs update-service \
|
||||
--cluster paperclip \
|
||||
--service paperclip-server \
|
||||
--task-definition paperclip-server:<PREVIOUS_REVISION>
|
||||
```
|
||||
|
||||
## Scaling to Zero (Cost Savings)
|
||||
|
||||
Scale down when not in use:
|
||||
|
||||
```bash
|
||||
# Stop
|
||||
aws ecs update-service \
|
||||
--cluster paperclip \
|
||||
--service paperclip-server \
|
||||
--desired-count 0
|
||||
|
||||
# Start
|
||||
aws ecs update-service \
|
||||
--cluster paperclip \
|
||||
--service paperclip-server \
|
||||
--desired-count 1
|
||||
```
|
||||
|
||||
RDS can also be stopped (auto-restarts after 7 days):
|
||||
|
||||
```bash
|
||||
aws rds stop-db-instance --db-instance-identifier paperclip-db
|
||||
aws rds start-db-instance --db-instance-identifier paperclip-db
|
||||
```
|
||||
|
||||
## Teardown
|
||||
|
||||
Remove all resources in reverse order:
|
||||
|
||||
```bash
|
||||
# 1. ECS service and cluster
|
||||
aws ecs update-service --cluster paperclip --service paperclip-server --desired-count 0
|
||||
aws ecs delete-service --cluster paperclip --service paperclip-server --force
|
||||
aws ecs delete-cluster --cluster paperclip
|
||||
|
||||
# 2. ALB and ACM cert
|
||||
aws elbv2 delete-listener --listener-arn $HTTP_LISTENER_ARN
|
||||
aws elbv2 delete-listener --listener-arn $LISTENER_ARN
|
||||
aws elbv2 delete-target-group --target-group-arn $TG_ARN
|
||||
aws elbv2 delete-load-balancer --load-balancer-arn $ALB_ARN
|
||||
aws acm delete-certificate --certificate-arn $CERT_ARN
|
||||
|
||||
# 3. RDS (creates final snapshot)
|
||||
aws rds delete-db-instance \
|
||||
--db-instance-identifier paperclip-db \
|
||||
--final-db-snapshot-identifier paperclip-db-final
|
||||
aws rds wait db-instance-deleted --db-instance-identifier paperclip-db
|
||||
aws rds delete-db-subnet-group --db-subnet-group-name paperclip-db-subnet
|
||||
|
||||
# 4. EFS (mount targets must be deleted first)
|
||||
for MT in $(aws efs describe-mount-targets --file-system-id $EFS_ID --query 'MountTargets[*].MountTargetId' --output text); do
|
||||
aws efs delete-mount-target --mount-target-id $MT
|
||||
done
|
||||
# Mount-target deletion is async; poll until none remain before deleting
|
||||
# the filesystem, otherwise delete-file-system fails with FileSystemInUse.
|
||||
echo "Waiting for mount targets to delete..."
|
||||
while aws efs describe-mount-targets \
|
||||
--file-system-id $EFS_ID \
|
||||
--query 'MountTargets[0].MountTargetId' --output text 2>/dev/null | grep -q 'fsmt-'; do
|
||||
sleep 5
|
||||
done
|
||||
aws efs delete-file-system --file-system-id $EFS_ID
|
||||
|
||||
# 5. Secrets
|
||||
for s in database-url anthropic-api-key better-auth-secret openai-api-key github-token; do
|
||||
aws secretsmanager delete-secret --secret-id paperclip/$s --force-delete-without-recovery
|
||||
done
|
||||
|
||||
# 6. Security groups (after all dependents are gone)
|
||||
for sg in $EFS_SG $RDS_SG $ECS_SG $ALB_SG; do
|
||||
aws ec2 delete-security-group --group-id $sg
|
||||
done
|
||||
|
||||
# 7. ECR
|
||||
aws ecr delete-repository --repository-name paperclip-server --force
|
||||
|
||||
# 8. IAM roles
|
||||
aws iam delete-role-policy --role-name paperclip-ecs-execution --policy-name SecretsAccess
|
||||
aws iam detach-role-policy --role-name paperclip-ecs-execution \
|
||||
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
||||
aws iam delete-role --role-name paperclip-ecs-execution
|
||||
aws iam delete-role --role-name paperclip-ecs-task
|
||||
|
||||
# 9. Log group
|
||||
aws logs delete-log-group --log-group-name /ecs/paperclip
|
||||
```
|
||||
|
||||
## Cost Reference
|
||||
|
||||
| Service | Config | Monthly |
|
||||
|---------|--------|---------|
|
||||
| ECS Fargate | 2 vCPU, 4 GB, 24/7 | ~$70 |
|
||||
| RDS Postgres | db.t4g.micro, 20 GB | ~$15 |
|
||||
| ALB | 1 LCU average | ~$22 |
|
||||
| NAT Gateway | 1 AZ (if using private subnets) | ~$35 |
|
||||
| EFS | 1 GB Standard | ~$0.30 |
|
||||
| Secrets Manager | 5 secrets | ~$2 |
|
||||
| CloudWatch Logs | ~1 GB/mo | ~$0.50 |
|
||||
| ECR | ~1 GB | ~$0.10 |
|
||||
| **Total (public subnets, no NAT)** | | **~$110/mo** |
|
||||
| **Total (private subnets + NAT)** | | **~$145/mo** |
|
||||
|
||||
Use Fargate Spot and scheduled scaling to 0 during off-hours to reduce to ~$60-85/mo.
|
||||
|
|
@ -40,7 +40,7 @@ Paperclip supports three deployment configurations, from zero-friction local to
|
|||
|
||||
- **Just trying Paperclip?** Use `local_trusted` (the default)
|
||||
- **Sharing with a team on private network?** Use `authenticated` + `private`
|
||||
- **Deploying to the cloud?** Use `authenticated` + `public`
|
||||
- **Deploying to the cloud?** Use `authenticated` + `public` — see [AWS ECS Fargate guide](aws-ecs.md)
|
||||
|
||||
Set the mode during onboarding:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue