Alojando Hugo en AWS con S3 y CloudFront

Microsoft .NET Hugo Amazon Web Services Amazon CloudFront Amazon Simple Storage Service

Introducción

Hugo ama AWS

Hugo es un generador de sitios estáticos que es una excelente herramienta para crear blogs, documentación y otros tipos de sitios web. Este sitio también está construido con Hugo y alojado en AWS utilizando S3 y CloudFront. Es una forma simple, rápida y flexible de alojar un sitio estático. No encontré una buena guía sobre cómo alojar un sitio de Hugo en AWS en su documentación, así que decidí escribir una.

En este artículo, aprenderemos cómo construir e implementar un sitio de Hugo en un nuevo bucket de S3 y una distribución de CloudFront recién creados utilizando AWS CDK.

Prerrequisitos

  • Hugo instalado en tu máquina
  • AWS CDK ya instalado
  • .NET ya que utilizaremos CDK en C#

Paso 1: Crear un nuevo sitio de Hugo

Asumo que ya tienes creado un sitio de Hugo. Si no es así, creemos uno ficticio para este ejemplo:

hugo new site mysite
cd mysite

git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml

hugo server

Sigue las instrucciones en el sitio web de Hugo para más detalles.

Paso 2: Construir el sitio Hugo

Construye el sitio Hugo ejecutando el siguiente comando:

cd mysite
hugo --minify --environment production

Esto creará una carpeta public con los archivos estáticos del sitio. Esta es la carpeta que desplegaremos en S3.

Paso 3: Usa AWS CDK para crear la infraestructura y desplegar el sitio

He creado un proyecto simple de CDK en C# que creará un bucket S3 y una distribución de CloudFront junto con todos los permisos, roles y configuraciones necesarios. Esto es lo que vamos a crear:

  • Bucket S3 para los archivos estáticos del sitio
  • Distribución de CloudFront para servir el sitio
  • Función de CloudFront en el evento de solicitud del espectador para:
    • Reescribir URLs para admitir las URLs amigables de Hugo (por ejemplo, /about/ en lugar de /about/index.html)
    • Redirigir www a URLs non-www
    • Redirigir URLs sin barra final a URLs con barra final para consistencia (por ejemplo, /about a /about/)

Veamos el código de CDK:

using Amazon.CDK;
using Amazon.CDK.AWS.CertificateManager;
using Amazon.CDK.AWS.CloudFront;
using Amazon.CDK.AWS.CloudFront.Origins;
using Amazon.CDK.AWS.IAM;
using Amazon.CDK.AWS.S3;
using Amazon.CDK.AWS.S3.Deployment;
using Constructs;
using System.Collections.Generic;
using static Amazon.CDK.AWS.CloudFront.CfnDistribution;
using static Amazon.CDK.AWS.CloudFront.CfnOriginAccessControl;

namespace Cdk
{
    public class HugoCdkStack : Stack
    {
        internal HugoCdkStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props)
        {
            var bucketNameParam = new CfnParameter(this, "BucketName", new CfnParameterProps
            {
                Type = "String",
                Description = "The name of the bucket to host the HUGO website",
                Default = "fastfoodcoding-hugo"
            });

            // uncomment the following lines to enable custom domain and ACM certificate
            //var domainNameParam = new CfnParameter(this, "DomainName", new CfnParameterProps
            //{
            //    Type = "String",
            //    Description = "The domain name to use for the CloudFront distribution. Default is null",
            //});

            //var certificateArnParam = new CfnParameter(this, "CertificateArn", new CfnParameterProps
            //{
            //    Type = "String",
            //    Description = "The ARN of the ACM certificate to use for the CloudFront distribution. Default is null",
            //});

            var s3Bucket = new Bucket(this, "HugoSiteBucket", new BucketProps
            {
                BucketName = bucketNameParam.ValueAsString,
                RemovalPolicy = RemovalPolicy.DESTROY,
                BlockPublicAccess = BlockPublicAccess.BLOCK_ALL,
            });

            var cfnOriginAccessControl = new CfnOriginAccessControl(this, "OriginAccessControl", new CfnOriginAccessControlProps
            {
                OriginAccessControlConfig = new OriginAccessControlConfigProperty
                {
                    Name = "HugoSiteBucket-OriginAccessControl",
                    OriginAccessControlOriginType = "s3",
                    SigningBehavior = "always",
                    SigningProtocol = "sigv4"
                }
            });

            // Hugo uses pretty URLs instead of index.html for every path
            // S3 or CloudFront does not support this out of the box
            // Let's use a CloudFront lightweight function on Viewer Request event to achieve that
            // As a bonus, for consistency we can redirect www to non-www and fix trailing slashes in URLs
            var redirectFunction = new Function(this, "RedirectFunction", new FunctionProps
            {
                FunctionName = "HugoSiteViewerRequestFunction",
                Runtime = FunctionRuntime.JS_2_0,
                Comment = "Redirect to index.html if the request is for a directory",
                Code = FunctionCode.FromInline(GetFunctionCode()),
            });

            var cfnDistribution = new Distribution(this, "HugoSiteDistribution", new DistributionProps
            {
                DefaultRootObject = "index.html",
                DefaultBehavior = new BehaviorOptions
                {
                    Origin = new S3OacOrigin(s3Bucket, new S3OriginProps
                    {
                        OriginAccessIdentity = null,
                        ConnectionAttempts = 3,
                        ConnectionTimeout = Duration.Seconds(10)
                    }),
                    CachedMethods = CachedMethods.CACHE_GET_HEAD,
                    CachePolicy = CachePolicy.CACHING_OPTIMIZED,
                    ViewerProtocolPolicy = ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
                    FunctionAssociations = new[]
                    {
                        new FunctionAssociation
                        {
                            EventType = FunctionEventType.VIEWER_REQUEST,
                            Function = redirectFunction
                        }
                    }
                },
                // uncomment the following lines to enable custom domain and ACM certificate
                //DomainNames = bucketNameParam.ValueAsString,
                //Certificate = Certificate.FromCertificateArn(this, "Certificate", certificateArnParam.ValueAsString)
            });

            s3Bucket.AddToResourcePolicy(new PolicyStatement(new PolicyStatementProps
            {
                Actions = ["s3:GetObject"],
                Principals = [new ServicePrincipal("cloudfront.amazonaws.com")],
                Effect = Effect.ALLOW,
                Resources = [s3Bucket.ArnForObjects("*")],
                Conditions = new Dictionary<string, object>
                {
                    ["StringEquals"] = new Dictionary<string, object>
                    {
                        ["AWS:SourceArn"] = $"arn:aws:cloudfront::{this.Account}:distribution/{cfnDistribution.DistributionId}"
                    }
                }
            }));

            // workaround using the L1 construct to attach the OriginAccessControl to the CloudFront Distribution
            var l1CfnDistribution = cfnDistribution.Node.DefaultChild as CfnDistribution;
            l1CfnDistribution.AddPropertyOverride("DistributionConfig.Origins.0.OriginAccessControlId", cfnOriginAccessControl.AttrId);

            // sync our Hugo website public/ folder with the S3 bucket
            // don't forget to run "hugo" before deploying the CDK stack
            var _ = new BucketDeployment(this, "DeployWebSite", new BucketDeploymentProps
            {
                // sync the contents of the public/ folder with the S3 bucket
                Sources = [Source.Asset("../mysite/public")],
                DestinationBucket = s3Bucket,
                // invalidate the cache on the CloudFront distribution when the website is updated
                Distribution = cfnDistribution,
                DistributionPaths = ["/*"],
            });
        }

        private static string GetFunctionCode()
        {
            return @"
             async function handler(event) {
                const request = event.request;
                const host = request.headers.host.value;
                const uri = request.uri;

                if (!request.uri.endsWith('/') && !request.uri.includes('.')) {
                    request.uri += '/';
                    return redirectResponse('https://' + host + request.uri);
                }

                if (host.startsWith('www.')) {
                    return redirectResponse('https://' + host.replace('www.', '') + request.uri);
                }

                if (uri.endsWith('/')) {
                    request.uri += 'index.html';
                }

                return request;
            }

            function redirectResponse(newurl) {
                return {
                    statusCode: 301,
                    statusDescription: 'Moved Permanently',
                    headers: { 'location': { 'value': newurl } }
                };
            }";
        }

    }
}

public class S3OacOrigin : OriginBase
{
    public S3OacOrigin(IBucket bucket, IOriginProps props = null) : base(bucket.BucketRegionalDomainName, props)
    {
    }

    // workaround to avoid the "OriginAccessIdentity" property to be rendered in the CloudFormation template
    protected override IS3OriginConfigProperty RenderS3OriginConfig()
    {
        return new S3OriginConfigProperty
        {
            OriginAccessIdentity = ""
        };
    }
}

Paso 4: Desplegar la pila de CDK

Para desplegar la pila de CDK, simplemente ejecuta los siguientes comandos:

cdk deploy --parameters BucketName=fastfoodcoding-hugo

Conclusión

¡Listo! Has desplegado exitosamente un sitio Hugo en AWS utilizando S3 y CloudFront. Ahora puedes acceder a tu sitio utilizando la URL de distribución de CloudFront. También puedes usar un dominio personalizado y un certificado ACM descomentando las líneas respectivas en el código de CDK. Sin embargo, esto requiere configuración adicional en Route 53 y ACM.