Skip to content

Instantly share code, notes, and snippets.

@windlessuser
Last active November 20, 2022 18:06
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save windlessuser/d9e27a79882a572b5f1eecc6a42655c0 to your computer and use it in GitHub Desktop.
Save windlessuser/d9e27a79882a572b5f1eecc6a42655c0 to your computer and use it in GitHub Desktop.
Deploys Mastodon using AWS CDK
import cdk = require('@aws-cdk/cdk');
import ec2 = require('@aws-cdk/aws-ec2');
import elastic = require('@aws-cdk/aws-elasticache');
import rds = require ('@aws-cdk/aws-rds');
import es = require("@aws-cdk/aws-elasticsearch");
import ecs = require('@aws-cdk/aws-ecs');
import s3 = require("@aws-cdk/aws-s3");
import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2');
import route53 = require('@aws-cdk/aws-route53');
import certificateManager = require("@aws-cdk/aws-certificatemanager");
import {Protocol} from "@aws-cdk/aws-ecs";
import {ApplicationProtocol} from "@aws-cdk/aws-elasticloadbalancingv2";
interface mastodonprops extends cdk.StackProps {
vpc: ec2.VpcNetwork,
}
// A stack that holds all the shared resources. In this case this vpc is used by everything
class SharedResources extends cdk.Stack {
vpc: ec2.VpcNetwork;
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Network to run everything in
this.vpc = new ec2.VpcNetwork(this, 'MastodonVpc', {
maxAZs: 2,
natGateways: 1
});
}
}
class RedisCluster extends cdk.Stack {
securityGroup: ec2.SecurityGroup;
connections: ec2.Connections;
cluster: elastic.CfnCacheCluster;
constructor(scope: cdk.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],
defaultPortRange: new ec2.TcpPort(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.subnetGroupName,
vpcSecurityGroupIds: [
this.securityGroup.securityGroupId,
]
});
};
}
export class Postgres extends cdk.Stack {
securityGroup: ec2.SecurityGroup;
connections: ec2.Connections;
subnetGroup: rds.CfnDBSubnetGroup;
db: rds.CfnDBInstance;
constructor(scope: cdk.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.CfnDBSubnetGroup(this, `${id}-subnet-group`, {
subnetIds: targetVpc.privateSubnets.map(subnet => subnet.subnetId),
dbSubnetGroupDescription: "Subnet for the PostgresDB"
});
this.connections = new ec2.Connections({
securityGroups: [this.securityGroup],
defaultPortRange: new ec2.TcpPort(5432)
});
this.db = new rds.CfnDBInstance(this, id, {
engine: rds.DatabaseEngine.Postgres,
engineVersion: "9.6.5",
autoMinorVersionUpgrade: true,
allowMajorVersionUpgrade: false,
multiAz: true,
dbInstanceClass: 'db.t2.micro',
storageType: 'gp2',
allocatedStorage: '10',
dbName: '<REDACTED>',
masterUserPassword: '<REDACTED>',
masterUsername: '<REDACTED>',
vpcSecurityGroups: [this.securityGroup.securityGroupId],
dbSubnetGroupName: this.subnetGroup.dbSubnetGroupName
})
}
}
export class ElasticSearch extends cdk.Stack {
securityGroup: ec2.SecurityGroup;
connections: ec2.Connections;
domain: es.CfnDomain;
constructor(scope: cdk.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],
defaultPortRange: new ec2.TcpPort(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 AwsecsdeployStack extends cdk.Stack {
constructor(scope: cdk.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 = new route53.HostedZoneProvider(this, {
domainName: 'ithance.com'
}).findAndImport(this, 'IThanceZone');
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.cacheClusterRedisEndpointAddress,
REDIS_PORT: redis.cluster.cacheClusterRedisEndpointPort,
SECRET_KEY_BASE: '<REDACTED>',
OTP_SECRET: '<REDACTED>',
S3_ENABLED: 'true',
S3_BUCKET: mastodonBucket.bucketName,
ES_ENABLED: 'true',
ES_HOST: search.domain.domainEndpoint,
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.fromAsset(this, 'MastodonImage', {
directory: '../'
});
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(this, 'web-logs', {
streamPrefix: '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.AliasRecord(this, "WebRecord", {
zone,
recordName: "mastodon",
target: webLB
});
WebListener.addCertificateArns("WebCert", [webCert.certificateArn]);
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(this, 'streaming-logs', {
streamPrefix: '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.AliasRecord(this, "StreamRecord", {
zone,
recordName: "mastodon-stream",
target: StreamLB
});
StreamListener.addCertificateArns("StreamCert", [streamCert.certificateArn]);
const sideKickTaskDefinition = new ecs.FargateTaskDefinition(this, "SidekickTask");
new ecs.ContainerDefinition(this, "SideKick", {
taskDefinition: sideKickTaskDefinition,
image,
environment,
command: ["bundle", "exec", "sidekiq"],
logging: new ecs.AwsLogDriver(this, 'worker-logs', {
streamPrefix: '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())
});
}
}
@eladb
Copy link

eladb commented Apr 11, 2019

This is pretty awesome!

P.S. Construct IDs are scoped, so no need to prefix them with the parent construct's ID (e.g. instead of ${id}-security-group you could just write security-group).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment