O objetivo é construir um pipeline para realizar o deploy de uma aplicação NextJS, com o código em um repositório do Github, em um bucket S3 da AWS.
A ideia é utilizá-lo em aplicações que utilizam o Server Side Generation(SSG). Isso se torna útil para lançar landing pages e hotsides em que as páginas já possuem o conteúdo definido.
Criando os repositórios
Vamos criar dois repositórios. O primeiro é a aplicação NextJS, e o segundo o código da infraestrutura.
# Criando a aplicação
npx create-next-app@latest pipeline-nextjs-site
# Iniciando a infraestrutura com CDK:
## Criar a pasta
mkdir pipeline-nextjs-infra && cd pipeline-nextjs-infra
## Inciando o projeto
cdk init app --language typescript
Configurando o output do NextJS
No código do NextJS vamos apenas alterar a parte da configuração de exportação. Lembrando que esse tutorial não tem como objetivo realizar configurações avançadas ou otimizações do NextJS, apenas servir a exportação em um Bucket S3.
Nosso arquivo next.config.ts deve ficar assim:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "export",
};
export default nextConfig;
Faça o commit e disponibilize o repositório na sua conta do Github. Pode ser um repositório privado. Tenho utilizado nos últimos meses a Github CLI e acho bastante prática. Sugiro dar uma olhada.
Configurações iniciais
Primeiro passo é criar um .env
:
AWS_ACCOUNT=
AWS_REGION=
GITHUB_TOKEN=
GITHUB_USERNAME=
Nesse .env
vamos guardar o código da conta AWS, a região que o serviço será disponibilizado, seu usuário do Github e um token para essa integração. Para gerar esse token, vá em sua conta do Github e siga esses passos:
- Clique em Settings e em seguida Developer settings.
- Clique em Personal access tokens, em seguida Tokens (classic).
- No select box do canto direito, clique em Genereate new token(classic)
- Conclua o processo de 2FA caso requisitado
- Nomeie o seu token, escolha uma data de expiração, selecione todas as opções repo, a opção workflow e clique em Generate token.
Com o token criado, cole-o no seu arquivo .env
.
Será preciso instalar a biblioteca dotenv
para utilizarmos o arquivo que criamos:
npm install dotenv
Criando as stacks com CDK
Stack IAM
O CDK trabalha com o conceito de stack para formar a infraestrutura que será disponibilizada na AWS. Para esse projeto, vamos criar três stacks: S3, IAM e Pipeline. Adotaremos a estratégia de separar os serviços da AWS por stack.
Na raiz do repositório da infra, vamos criar um diretório chamado lib e dentro dele o primeiro arquivo de stack: iam-stack.ts
Nessa estarão as permissões para o CDK realizar tarefas como salvar arquivos em um bucket S3.
Temos que criar uma classe estendendo a classe Stack
:
export class IamStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps){
super(scope, id, props)
}
}
Agora, é preciso adicionar politicas de acesso. Vamos criar uma referência ao serviço, uma politica de acesso completo ao S3, acesso ao Code Build como administrador e por último criar o papel de build:
export class IamStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps){
super(scope, id, props)
const code_build_service = new iam.ServicePrincipal("codebuild.amazonaws.com")
const s3_full_access = iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3FullAccess")
const codebuild_admin_access = iam.ManagedPolicy.fromAwsManagedPolicyName("AWSCodeBuildAdminAccess")
this.build_role = new iam.Role(this, "BuildRole", {
assumedBy: code_build_service,
description: "Role for codebuild"
})
}
}
Para concluir essa stack, adicionaremos as políticas de acesso ao papel que criamos e salvar o ARN do papel em uma propriedade da classe. O arquivo completo deve ficar dessa forma:
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
export class IamStack extends cdk.Stack {
private readonly build_role: iam.Role
public readonly build_role_arn: string;
constructor(scope: Construct, id: string, props?: cdk.StackProps){
super(scope, id, props)
const code_build_service = new iam.ServicePrincipal("codebuild.amazonaws.com")
const s3_full_access = iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3FullAccess")
const codebuild_admin_access = iam.ManagedPolicy.fromAwsManagedPolicyName("AWSCodeBuildAdminAccess")
this.build_role = new iam.Role(this, "BuildRole", {
assumedBy: code_build_service,
description: "Role for codebuild"
})
this.build_role.addManagedPolicy(s3_full_access)
this.build_role.addManagedPolicy(codebuild_admin_access)
this.build_role_arn = this.build_role.roleArn
}
}
Stack S3
A próxima stack será do S3. Nesse caso, vamos precisar acrescentar informações na interface de propriedades dessa stack, portanto vamos estender dessa forma:
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
interface S3StackProps extends cdk.StackProps {
build_role_arn: string
}
export class S3Stack extends cdk.Stack {
public readonly bucket: s3.Bucket;
public readonly build_role: iam.Role
constructor(scope: Construct, id: string, props: S3StackProps){
super(scope, id, props);
}
}
Em seguida, precisamos recuperar o papel do IAM criado na outra Stack a partir do ARN que passado no construtor da classe:
const build_role = iam.Role.fromRoleArn(this, "ImportBuildRole", props.build_role_arn)
Agora, criaremos o recurso do Bucket em si, onde definiremos as propriedades para servir os arquivos da build realizada pelo NextJS:
this.bucket = new s3.Bucket(this, "BucketNextJSSite", {
websiteIndexDocument: "index.html",
websiteErrorDocument: "404.html",
publicReadAccess: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true
})
A Propriedade websiteIndexDocument
é referente ao arquivo html inicial do site, enquanto websiteErrorDocument
é o arquivo padrão para erro 404 caso alguma página não tenha sido encontrada.
removalPolicy
e autoDeleteObjects
são propriedades para remover os arquivos do bucket, caso opte por destruir o projeto do CDK, removendo todos os recursos.
Para conseguir expor o acesso do site aos usuários, as configurações de publicReadAccess
e blockPublicAccess
são responsáveis por isso.
O ambiente da AWS é bastante robusto no requisito de autorizações. Praticamente para tudo que um recurso precise executar em outro recurso, será preciso algum tipo de autorização.
Para finalizar a stack do S3, é preciso salvar a url do website criado pelo bucket para a utilização dentro de nosso ambiente. E também será preciso salvar o nome do bucket criado no serviço AWS Systems Manager(SSM) para utilizar mais a frente quando o pipeline for configurado. O código final será esse:
export class S3Stack extends cdk.Stack {
public readonly bucket: s3.Bucket;
public readonly build_role: iam.Role
constructor(scope: Construct, id: string, props: S3StackProps){
super(scope, id, props);
const build_role = iam.Role.fromRoleArn(this, "ImportBuildRole", props.build_role_arn)
this.bucket = new s3.Bucket(this, "BucketNextJSSite", {
websiteIndexDocument: "index.html",
websiteErrorDocument: "404.html",
publicReadAccess: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true
})
this.bucket.grantReadWrite(build_role)
this.bucket.addToResourcePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [new iam.ArnPrincipal(build_role.roleArn)],
actions: [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
resources: [
this.bucket.bucketArn,
this.bucket.arnForObjects("*")],
}))
new cdk.CfnOutput(this, "BucketNextJSSiteURL", {
value: this.bucket.bucketWebsiteUrl,
description: "URL do site hospedado no S3",
exportName: "BucketNextJSSiteURL"
})
new ssm.StringParameter(this, 'BucketNextJSSiteNameSSM', {
parameterName: 'BucketNextJSSiteName',
stringValue: this.bucket.bucketName
})
}
}
Stack CodePipeline
A última stack será responsável pelo pipeline que entregará a build do projeto. Aqui também é preciso uma interface para as propriedades, pois precisaremos receber o bucket e o ARN do papel criado anteriormente:
interface PipelineStackProps extends cdk.StackProps {
bucket: s3.Bucket;
build_role_arn: string;
}
Inciando a classe da stack, precisaremos recuperar o papel de build a partir da ARN injetada e utilizar iniciar um novo objeto referente ao projeto de pipeline:
export class PipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: PipelineStackProps) {
super(scope, id, props);
const build_role = iam.Role.fromRoleArn(this, "ImportBuildRole", props.build_role_arn)
const buildProject = new codebuild.PipelineProject(this, "BuildNextJSSite", {
projectName: "BuildNextJSSite",
role: build_role,
environment:{
buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
environmentVariables: {
BUCKET_NAME: {
value: props.bucket.bucketName
}
},
},
buildSpec: codebuild.BuildSpec.fromObject({
version: '0.2',
phases:{
install: {
'runtime-versions': {
nodejs: 20
},
commands:[
'npm install'
]
},
build: {
commands: [
'npm run build',
'aws s3 sync ./out s3://$BUCKET_NAME --delete'
]
}
},
artifacts: {
files: [ '**/*' ],
'base-directory': 'out'
}
})
})
}
}
Nessa primeira etapa, utilizaremos o nome do bucket a partir da propriedade injetada para configurar o ambinete. Além disso, as configurações de linha de comando também são configuradas nesse objeto.
Em seguida, vamos criar uma constante para o objeto do Pipeline:
const pipeline = new codepipeline.Pipeline(this, "PipelineNextJSSite", {
pipelineName: "NextJSSitePipeline",
crossAccountKeys: false,
});
Um ponto de maior atenção aqui. O objetivo desse tutorial é mostrar um exemplo de fluxo para o deploy. Vou apresentar duas formas de buscar o token do Github. A primeira maneira é buscando pela variável de ambiente. Não é a recomendada pela AWS, mas a utilizaremos para poupar custo enquanto desenvolvemos:
const githubToken = cdk.SecretValue.unsafePlainText(process.env.GITHUB_TOKEN!);
A AWS recomenda utilizarmos o serviço Secrets Manager para administrar chaves de api e informações sensíveis. Utilize a maneira a seguir quando for disponibilzar o seu site ou aplicação em produção:
Entre no painel da sua conta na AWS. Em Secrets Menager, adicione um secret github-token
com seu token. Para buscá-lo:
const githubToken = cdk.SecretValue.secretsManager('github-token');
Basicamente, cada segredo no Secrets Manager custa 0.40 dólares por mês, o que pode ser um custo desnecessário durante o processo de desenvolvimento ou para uma simples POC.
Com o token pronto, vamos criar um Artifact e em seguida adicionar um estágio ao pipeline:
pipeline.addStage({
stageName: "Source",
actions: [
new codepipeline_actions.GitHubSourceAction({
actionName: "BuildAndDeployNextJSSite",
owner: "RaphaAmericanoDev",
repo: "next-js-website",
branch: "deploy",
oauthToken: githubToken,
output: sourceOutput
}),
],
});
Nesse estágio selecionamos o owner, seu username do Github, e o repositório do projeto NextJS em repo. Todos os commits na branch ´deploy´ irão disparar a build. Isso é especialmente útil para evitar builds desnecessárias e apenas quando de fato alguma funcionalidade nova precisa ser levada ao ambiente.
Vamos adicionar o token do Github que recuperamos e o artefato onde a build será feita.
Por último, vamos adicionar um segundo estágio do pipeline onde preenchemos com o projeto de Pipeline e o artefato:
pipeline.addStage({
stageName: 'Build',
actions:[
new codepipeline_actions.CodeBuildAction({
actionName: "BuildAndDeployNextJSSite",
project: buildProject,
input: sourceOutput
}),
]
})
Reunindo as stacks e montando a infraestrutura
Agora que concluímos todas as stacks, vamos criar um arquivo para montar tudo. O setup inicial do CDK cria uma pasta bin
na raiz do projeto com um arquivo de exemplo. Vamos limpá-lo, iniciar os dados do ´.env´, criar a constante de Environment
do CDK, uma constante de tags e iniciar o app:
import * as dotenv from 'dotenv';
import * as cdk from 'aws-cdk-lib';
import { PipelineStack } from '../lib/pipeline-stack';
import { S3Stack } from '../lib/s3-stack';
import { IamStack } from '../lib/iam-stack';
dotenv.config();
const env:cdk.Environment = {
account: process.env.AWS_ACCOUNT,
region: process.env.AWS_REGION
}
const tags = {
cost: "NextJSSite",
region: "us-east-1",
}
const app = new cdk.App();
Em todas as stacks, vamos adicionar o env
e tags
.
A primeira stack instanciada deve ser a IamStack:
const iamStack = new IamStack(app, 'NextJSSiteIamStack', {
env: env,
tags: tags,
})
Em seguida, ao criar stack do S3 será preciso adicionar build_role_arn
da instância de IamStack. Além disso, é preciso adicionar o iamStack
com dependência do stack do S3 já que não é possivel manipular dados no bucket sem a autorização criada previamente no IAM:
const s3Stack = new S3Stack(app, 'NextJSSiteS3Stack', {
build_role_arn: iamStack.build_role_arn,
env: env,
tags: tags,
});
s3Stack.addDependency(iamStack)
O próximo passo é instanciar um objeto do PipelineStack passando o bucket que criamos assim como o ARN do papel de build:
const pipelineStack = new PipelineStack(app, 'NextJSSitePipelineStack', {
bucket: s3Stack.bucket,
build_role_arn: iamStack.build_role_arn,
env: env,
tags: tags,
})
Aqui também precisamos adicionar as stacks de IAM e S3 como dependências da stack de pipeline pela mesma razão da stack anterior:
pipelineStack.addDependency(iamStack)
pipelineStack.addDependency(s3Stack)
Chegou a hora do deploy! Execute o comando de synth
para verificar se tudo está correto:
cdk synth
Caso tudo esteja correto, execute o comando deploy
. O CLI vai pedir algumas autorizações e confirmações, aperte y
para ir seguindo:
cdk deploy --all
No painel da AWS, entre na parte do S3. Dentro de Buckets em geral, procure o bucket que possui os arquivos do deploy e clique no index.html
. Na página do arquivo, clique no link Url de objeto e estará a primeira página da aplicação NextJS.
Esse pipeline pode ser útil para apresentar sites para clientes, criar ambientes de validação ou mesmo criação de POCs. Cada pipeline ativo tem um custo mensal de 1USD.
Caso seja necessário avançar para uma entrega mais completa, elegante e pronta para produção, o próximo passo seria configurar o Route 53 para ter uma url amigável e apropriada. Mas isso é assunto para um pŕoximo artigo. Até lá!
Top comments (0)