Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 41 additions & 24 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ jobs:
if: github.event.pull_request.merged
runs-on: ubuntu-latest
env:
EB_BUCKET: ${{vars.EB_BUCKET}}
EB_APP_NAME: ${{vars.EB_APP_NAME}}
EB_API_ENV_NAME: ${{vars.EB_API_ENV_NAME}}
EB_WORKER_ENV_NAME: ${{vars.EB_WORKER_ENV_NAME}}
CODE_BUCKET: ${{vars.CODE_BUCKET}}
REGION: ${{ vars.AWS_REGION }}
EC2_INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }}

steps:
- name: Checkout
Expand Down Expand Up @@ -56,30 +55,48 @@ jobs:
name: Upload to S3
run: |
FILE_NAME="app-${{ github.sha }}.zip"
aws s3 cp app.zip s3://$EB_BUCKET/$FILE_NAME
aws s3 cp app.zip s3://$CODE_BUCKET/$FILE_NAME
echo "FILE_NAME=$FILE_NAME" >> $GITHUB_ENV

- if: ${{ steps.cache-api.outputs.cache-hit != 'true' }}
name: Create new Elastic Beanstalk Application Version
name: Update EC2 from S3 and restart docker-compose
run: |
VERSION_LABEL="v-${{ github.run_number }}-${{ github.sha }}"
aws elasticbeanstalk create-application-version \
--application-name "$EB_APP_NAME" \
--version-label $VERSION_LABEL \
--source-bundle S3Bucket="$EB_BUCKET",S3Key="$FILE_NAME"
echo "VERSION_LABEL=$VERSION_LABEL" >> $GITHUB_ENV
# Send SSM command to sync code, rebuild, restart, and run migrations
aws ssm send-command \
--document-name "AWS-RunShellScript" \
--targets "Key=instanceids,Values=$EC2_INSTANCE_ID" \
--comment "Deploy $GITHUB_SHA" \
--parameters commands='[
"set -e",
"cd /var/www/app",
"aws s3 cp s3://'"$CODE_BUCKET"'/'"$FILE_NAME"' app.zip --region '"$REGION"'",
"unzip -o app.zip,
"chown -R ec2-user:ec2-user /var/www/app", #//??
"sudo -u ec2-user /usr/local/bin/docker-compose up -d --build", #//??
"sleep 15",
"sudo -u ec2-user /usr/local/bin/docker-compose exec -T api npm run migration:run"
]' /
--region "$REGION" \
--query "Command.CommandId" \
--output text > command_id.txt

COMMAND_ID=$(cat command_id.txt)
echo "SSM CommandId: $COMMAND_ID"

# Optionally wait for completion
aws ssm wait command-executed \
--command-id "$COMMAND_ID" \
--instance-id "$EC2_INSTANCE_ID" \
--region "$REGION"

# Fetch and print last 100 lines of output for visibility
aws ssm list-command-invocations \
--command-id "$COMMAND_ID" \
--details --region "$REGION" \
--query "CommandInvocations[0].CommandPlugins[0].Output" \
--output text | tail -n 100 || true



- if: ${{ steps.cache-api.outputs.cache-hit != 'true' }}
name: Update Environment API
run: |
aws elasticbeanstalk update-environment \
--environment-name "$EB_API_ENV_NAME" \
--version-label "$VERSION_LABEL"

- if: ${{ steps.cache-api.outputs.cache-hit != 'true' }}
name: Update Environment WORKER
run: |
aws elasticbeanstalk update-environment \
--environment-name "$EB_WORKER_ENV_NAME" \
--version-label "$VERSION_LABEL"

19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,22 @@ echo $INSTANCE_IP
```bash
ssh -i ~/.ssh/simplenestjs-dev-key.pem ec2-user@$INSTANCE_IP
```

# CI/CD on GitHub

## 1. add sectrets

gather these secrets from your aws console (create access key for user ionicapp-userdeployer)

AWS_ACCESS_KEY_ID=... (access key form console)
AWS_SECRET_ACCESS_KEY=... (secret key form console)

## 2. add variables

gather these variables from cdk output or aws cli

CODE_BUCKET=... (cdk output: ionicapp-devStack.CodeBucketName)
EC2_INSTANCE_ID=... (cdk output: ionicapp-devStack.InstanceId)
AWS_REGION=... (aws cli command: aws configure get region)


4 changes: 2 additions & 2 deletions infra/bin/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export interface IAppStackConfig {
databaseName: string;
domainName: string;
projectName: string;
subDomainNameApi: string;
fullSubDomainNameApi: string;
subDomainNameApp: string;
fullSubDomainNameApp: string;
userDeploerName: string;
databaseUsername: string;
targetNodeEnv: string;
Expand Down
10 changes: 5 additions & 5 deletions infra/config.dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ const projectName = projectShortName + suffix;
// define your registered domain (you must have one at Route53)
const domainName = 'for-test.click';

// subdomain for api (will be created)
const subDomainNameApi = `api.${projectName}`;
const fullSubDomainNameApi = `${subDomainNameApi}.${domainName}`;
// subdomain for app (will be created, and route .../api will be used to serve api )
const subDomainNameApp = `${projectName}`;
const fullSubDomainNameApp = `${subDomainNameApp}.${domainName}`;

// user for deployment using CI/CD (will be created)
const userDeploerName = `${projectName}-deployer`;
Expand All @@ -32,8 +32,8 @@ export const config: IAppStackConfig = {
databaseName,
domainName,
projectName,
subDomainNameApi,
fullSubDomainNameApi,
subDomainNameApp,
fullSubDomainNameApp,
userDeploerName,
databaseUsername,
targetNodeEnv,
Expand Down
137 changes: 123 additions & 14 deletions infra/lib/infra-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ export class InfraStack extends cdk.Stack {
props?: cdk.StackProps,
) {
super(scope, id, { ...props, crossRegionReferences: true });
const { projectName, domainName, fullSubDomainNameApi, subDomainNameApi } =
config;
const {
projectName,
domainName,
fullSubDomainNameApp,
subDomainNameApp,
userDeploerName,
} = config;
/**
*
*
Expand Down Expand Up @@ -147,7 +152,7 @@ export class InfraStack extends cdk.Stack {
certificateStack,
`${projectName}Certificate`,
{
domainName: fullSubDomainNameApi,
domainName: fullSubDomainNameApp,
validation: acm.CertificateValidation.fromDns(
route53.HostedZone.fromLookup(
certificateStack,
Expand Down Expand Up @@ -231,7 +236,7 @@ export class InfraStack extends cdk.Stack {
const userData = ec2.UserData.forLinux();
userData.addCommands(
'yum update -y',
'yum install -y docker git',
'yum install -y docker git unzip',
'systemctl start docker',
'systemctl enable docker',
'usermod -a -G docker ec2-user',
Expand Down Expand Up @@ -308,7 +313,8 @@ export class InfraStack extends cdk.Stack {
),
});

// Create key pair
// Tag instance for easy SSM targeting
cdk.Tags.of(ec2Instance).add('Name', `${projectName}-ec2`);

/**
*
Expand All @@ -320,34 +326,127 @@ export class InfraStack extends cdk.Stack {
*
*/

// S3 bucket for frontend
const frontendBucket = new s3.Bucket(this, `${projectName}FrontendBucket`, {
bucketName: `${projectName}-frontend-${this.account}`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'index.html', // For SPA routing
publicReadAccess: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS_ONLY,
});

// add cloudfront distribution to handle https
const distribution = new cloudfront.Distribution(
this,
`${projectName}Distribution`,
{
defaultBehavior: {
origin: new origins.HttpOrigin(ec2Instance.instancePublicDnsName, {
httpPort: 3000,
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, // Force HTTP to origin
}),
// Frontend as default behavior
origin: new origins.S3StaticWebsiteOrigin(frontendBucket),
viewerProtocolPolicy:
cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, // Important for REST (GET, POST, PUT, DELETE)
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, // For API responses
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
additionalBehaviors: {
'/api/*': {
// API behavior
origin: new origins.HttpOrigin(ec2Instance.instancePublicDnsName, {
httpPort: 3000,
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
}),
viewerProtocolPolicy:
cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
},
},
domainNames: [fullSubDomainNameApi],

domainNames: [fullSubDomainNameApp],
certificate: certificate,
},
);

new route53.ARecord(this, `${projectName}ARecord`, {
zone: zone,
recordName: subDomainNameApi,
recordName: subDomainNameApp,
target: route53.RecordTarget.fromAlias(
new route53targets.CloudFrontTarget(distribution),
),
});

/**
*
*
*
* USER DEPLOYER
*
*
*
*/

// Add IAM user to deploy code
const userDeploer = new iam.User(this, `${projectName}Deployer`, {
userName: userDeploerName,
});

userDeploer.attachInlinePolicy(
new iam.Policy(this, `${projectName}DeployerPolicy`, {
policyName: `publish-to-${projectName}`,
statements: [
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
effect: iam.Effect.ALLOW,
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/${projectName}*`,
],
}),

new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:*'],
resources: [
`arn:aws:s3:::cdk-hnb659fds-assets-${this.account}-${this.region}`,
`arn:aws:s3:::cdk-hnb659fds-assets-${this.account}-${this.region}/*`,
],
}),

// Allow publishing artifacts to the dedicated code bucket
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:*'],
resources: [codeBucket.bucketArn, `${codeBucket.bucketArn}/*`],
}),

// Allow triggering SSM RunCommand to restart docker on the instance
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'ssm:SendCommand',
'ssm:ListCommandInvocations',
'ssm:ListCommands',
],
resources: ['*'],
}),

// EC2 permissions for EB environment management
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ec2:Describe*'],
resources: ['*'],
}),

// CloudWatch Logs permissions
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['logs:*'],
resources: ['*'],
}),
],
}),
);

/**
*
*
Expand All @@ -368,9 +467,19 @@ export class InfraStack extends cdk.Stack {
});

// Output the EC2 instance ID
new cdk.CfnOutput(this, 'InstanceId', {
new cdk.CfnOutput(this, 'EC2 InstanceId', {
value: ec2Instance.instanceId,
description: 'EC2 instance ID',
});

new cdk.CfnOutput(this, 'UserDeploerName', {
value: userDeploerName,
description: 'User deployer name',
});

new cdk.CfnOutput(this, 'FrontendBucketName', {
value: frontendBucket.bucketName,
description: 'S3 bucket for frontend deployment',
});
}
}
Loading