Skip to content

Instantly share code, notes, and snippets.

@revmischa
Forked from windlessuser/awsecsdeploy-stack.ts
Created November 20, 2022 18:06
Show Gist options
  • Save revmischa/07bf2ce470863967fcc5f3c4b423f8d7 to your computer and use it in GitHub Desktop.
Save revmischa/07bf2ce470863967fcc5f3c4b423f8d7 to your computer and use it in GitHub Desktop.
Deploys Mastodon using AWS CDK v2
import cdk = require("aws-cdk-lib");
import ec2 = require("aws-cdk-lib/aws-ec2");
import elastic = require("aws-cdk-lib/aws-elasticache");
import rds = require("aws-cdk-lib/aws-rds");
import es = require("aws-cdk-lib/aws-elasticsearch");
import ecs = require("aws-cdk-lib/aws-ecs");
import s3 = require("aws-cdk-lib/aws-s3");
import elbv2 = require("aws-cdk-lib/aws-elasticloadbalancingv2");
import route53 = require("aws-cdk-lib/aws-route53");
import certificateManager = require("aws-cdk-lib/aws-certificatemanager");
import { Protocol } from "aws-cdk-lib/aws-ecs";
import { ApplicationProtocol } from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { Construct } from "constructs";
import { Port } from "aws-cdk-lib/aws-ec2";
import { StorageType } from "aws-cdk-lib/aws-rds";
import { AlbTarget } from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
import * as route53_targets from "aws-cdk-lib/aws-route53-targets";
interface mastodonprops extends cdk.StackProps {
vpc: ec2.Vpc;
}
// A stack that holds all the shared resources. In this case this vpc is used by everything
class SharedResources extends cdk.Stack {
vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Network to run everything in
this.vpc = new ec2.Vpc(this, "MastodonVpc", {
natGateways: 1,
});
}
}
class RedisCluster extends cdk.Stack {
securityGroup: ec2.SecurityGroup;
connections: ec2.Connections;
cluster: elastic.CfnCacheCluster;
constructor(scope: Construct, id: string, props: mastodonprops) {
super(scope, id, props);
const targetVpc = props.vpc;
// Define a group for telling Elasticache which subnets to put cache nodes in.
const subnetGroup = new elastic.CfnSubnetGroup(this, `${id}-subnet-group`, {
description: `List of subnets used for redis cache ${id}`,
subnetIds: targetVpc.privateSubnets.map((subnet) => subnet.subnetId),
});
// The security group that defines network level access to the cluster
this.securityGroup = new ec2.SecurityGroup(this, `${id}-security-group`, {
vpc: targetVpc,
});
this.connections = new ec2.Connections({
securityGroups: [this.securityGroup],
defaultPort: Port.tcp(6379),
});
// The cluster resource itself.
this.cluster = new elastic.CfnCacheCluster(this, `${id}-cluster`, {
cacheNodeType: "cache.t2.micro",
engine: "redis",
numCacheNodes: 1,
autoMinorVersionUpgrade: true,
cacheSubnetGroupName: subnetGroup.cacheSubnetGroupName,
vpcSecurityGroupIds: [this.securityGroup.securityGroupId],
});
}
}
export class Postgres extends cdk.Stack {
securityGroup: ec2.SecurityGroup;
connections: ec2.Connections;
subnetGroup: rds.SubnetGroup;
db: rds.DatabaseInstance;
constructor(scope: Construct, id: string, props: mastodonprops) {
super(scope, id, props);
const targetVpc = props.vpc;
// The security group that defines network level access to the cluster
this.securityGroup = new ec2.SecurityGroup(this, `${id}-security-group`, {
vpc: targetVpc,
});
this.subnetGroup = new rds.SubnetGroup(this, `${id}-subnet-group`, {
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
description: "Subnet for PostgresDB",
vpc: targetVpc,
});
this.connections = new ec2.Connections({
securityGroups: [this.securityGroup],
defaultPort: Port.tcp(5432),
});
this.db = new rds.DatabaseInstance(this, id, {
vpc: targetVpc,
engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_14_4,
}),
autoMinorVersionUpgrade: true,
allowMajorVersionUpgrade: false,
multiAz: true,
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.MICRO
),
storageType: StorageType.STANDARD,
securityGroups: [this.securityGroup],
subnetGroup: this.subnetGroup,
});
}
}
export class ElasticSearch extends cdk.Stack {
securityGroup: ec2.SecurityGroup;
connections: ec2.Connections;
domain: es.CfnDomain;
constructor(scope: Construct, id: string, props: mastodonprops) {
super(scope, id, props);
const targetVpc = props.vpc;
// The security group that defines network level access to the cluster
this.securityGroup = new ec2.SecurityGroup(this, `${id}-security-group`, {
vpc: targetVpc,
});
this.connections = new ec2.Connections({
securityGroups: [this.securityGroup],
defaultPort: Port.tcp(9200),
});
this.domain = new es.CfnDomain(this, id, {
vpcOptions: {
subnetIds: [targetVpc.privateSubnets[0].subnetId],
securityGroupIds: [this.securityGroup.securityGroupId],
},
elasticsearchClusterConfig: {
instanceType: "t2.small.elasticsearch",
},
ebsOptions: {
ebsEnabled: true,
volumeSize: 10,
volumeType: "gp2",
},
});
}
}
export class MastodonStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// The code that defines your stack goes here
const shared = new SharedResources(this, "SharedStuff");
const search = new ElasticSearch(this, "MastodonSearch", {
vpc: shared.vpc,
});
const redis = new RedisCluster(this, "MastodonRedis", {
vpc: shared.vpc,
});
const postgres = new Postgres(this, "MastodonDB", { vpc: shared.vpc });
const mastodonBucket = new s3.Bucket(this, "MastodonBucket", {
publicReadAccess: true,
});
const mastodonCluster = new ecs.Cluster(this, "MastodonCluster", {
vpc: shared.vpc,
});
const zone = route53.HostedZone.fromLookup(this, "MastodonZone", {
domainName: "mastodon.example.com",
});
const environment = {
DB_HOST: postgres.db.dbInstanceEndpointAddress,
DB_PORT: postgres.db.dbInstanceEndpointPort,
LOCAL_DOMAIN: "mastodon.example.com",
STREAMING_API_BASE_URL: "mastodon-stream.example.com",
DB_PASS: "<REDACTED>",
DB_USER: "<REDACTED>",
DB_NAME: "<REDACTED>",
REDIS_HOST: redis.cluster.attrRedisEndpointAddress,
REDIS_PORT: redis.cluster.attrRedisEndpointPort,
SECRET_KEY_BASE: "<REDACTED>",
OTP_SECRET: "<REDACTED>",
S3_ENABLED: "true",
S3_BUCKET: mastodonBucket.bucketName,
ES_ENABLED: "true",
ES_HOST: search.domain.attrDomainEndpoint,
SMTP_SERVER: "smtp.mailgun.org",
SMTP_PORT: "587",
SMTP_LOGIN: "<REDACTED>",
SMTP_PASSWORD: "<REDACTED>",
VAPID_PRIVATE_KEY: "<REDACTED>",
VAPID_PUBLIC_KEY: "<REDACTED>",
};
const image = ecs.ContainerImage.fromRegistry("tootsuite/mastodon");
const webTaskDefinition = new ecs.FargateTaskDefinition(this, "WebTask");
const web = new ecs.ContainerDefinition(this, "web", {
taskDefinition: webTaskDefinition,
image,
environment,
command: [
"bash",
"-c",
"bundle exec rake db:migrate; bundle exec rails s -p 3000 -b '0.0.0.0'",
],
logging: new ecs.AwsLogDriver({ streamPrefix: "mastodon/web" }),
});
web.addPortMappings({
containerPort: 3000,
protocol: Protocol.TCP,
});
const webService = new ecs.FargateService(this, "MastodonWebService", {
taskDefinition: webTaskDefinition,
cluster: mastodonCluster,
});
const webLB = new elbv2.ApplicationLoadBalancer(this, "WebLB", {
vpc: shared.vpc,
internetFacing: true,
});
const WebListener = webLB.addListener("WebListener", { port: 443 });
WebListener.addTargets("webService", {
port: 3000,
protocol: ApplicationProtocol.HTTP,
targets: [webService],
});
const webCert = new certificateManager.DnsValidatedCertificate(
this,
"MastodonWebCert",
{
hostedZone: zone,
domainName: "mastodon.example.com",
}
);
new route53.ARecord(this, "WebRecord", {
zone,
recordName: "mastodon",
target: route53.RecordTarget.fromAlias(
new route53_targets.LoadBalancerTarget(webLB)
),
});
WebListener.addCertificates("WebCert", [webCert]);
const StreamTaskDefinition = new ecs.FargateTaskDefinition(
this,
"StreamingTask"
);
const streaming = new ecs.ContainerDefinition(this, "streaming", {
taskDefinition: StreamTaskDefinition,
image,
environment,
command: ["yarn", "start"],
logging: new ecs.AwsLogDriver({
streamPrefix: "mastodon/streaming",
}),
});
streaming.addPortMappings({
containerPort: 4000,
protocol: Protocol.TCP,
});
const StreamingService = new ecs.FargateService(
this,
"MastodonStreamingService",
{
taskDefinition: StreamTaskDefinition,
cluster: mastodonCluster,
}
);
const StreamLB = new elbv2.ApplicationLoadBalancer(this, "StreambLB", {
vpc: shared.vpc,
internetFacing: true,
});
const StreamListener = StreamLB.addListener("StreamListener", {
port: 443,
});
StreamListener.addTargets("StreamService", {
port: 4000,
protocol: ApplicationProtocol.HTTP,
targets: [StreamingService],
});
const streamCert = new certificateManager.DnsValidatedCertificate(
this,
"MastodonSteamCert",
{
hostedZone: zone,
domainName: "mastodon-stream.example.com",
}
);
new route53.ARecord(this, "StreamRecord", {
zone,
recordName: "mastodon-stream",
target: route53.RecordTarget.fromAlias(
new route53_targets.LoadBalancerTarget(StreamLB)
),
});
StreamListener.addCertificates("StreamCert", [streamCert]);
const sideKickTaskDefinition = new ecs.FargateTaskDefinition(
this,
"SidekickTask"
);
new ecs.ContainerDefinition(this, "SideKick", {
taskDefinition: sideKickTaskDefinition,
image,
environment,
command: ["bundle", "exec", "sidekiq"],
logging: new ecs.AwsLogDriver({
streamPrefix: "mastodon/worker",
}),
});
const sideKickService = new ecs.FargateService(this, "Worker", {
taskDefinition: sideKickTaskDefinition,
cluster: mastodonCluster,
});
[webService, StreamingService, sideKickService].map((service) => {
service.connections.allowToDefaultPort(redis);
service.connections.allowToDefaultPort(search);
service.connections.allowToDefaultPort(postgres);
mastodonBucket.grantReadWrite(
service.taskDefinition.obtainExecutionRole()
);
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment