🌟

amplify cli で 作った Cloudfront + S3 にカスタムドメインを紐づける

2023/12/10に公開

amplify cli で構築した環境にカスタムドメインを紐づける方法を紹介します。

はじめに

AWS Amplify を使用すると、フロントエンド開発者でも簡単にインフラを構築できます。特に、公開時にカスタムドメインの設定が重要になることがあります。この記事では、Amplify CLI を使った S3 と CloudFront 環境へのカスタムドメインの紐付け方法について解説します。

前提

以下の前提で環境構築していることを想定しています。

  • amplify cli を使ってインフラ構築
    • フロントエンドは amplify add hostingS3 + CloudFront での構築
    • API は amplify add apiREST APIを選択し、apigateway + lambda function での構築
  • Route53 でドメインを取得
  • Route53 でホストゾーンを作成
  • us-east-1 の ACM で証明書を取得。(cloudfront に紐づける証明書なのでus-east-1で取得している必要があります。)
    以下のドメインの証明書を取得しておきます。(my-domain.com は自分のドメイン名に置き換えてください)
    • my-domain.com
    • www.my-domain.com
    • *.my-domain.com
    • www.dev.my-domain.com

CloudFront へのカスタムドメインの設定

Cloudfront にカスタムドメインを紐づける方法について

amplify add hosting を実行して S3 + CloudFront での構築を選択すると、デフォルトでは AWS が用意しているドメイン(hoge.cloudfront.net)が Cloudfront に紐づけられます。

またその際、プロジェクトのフォルダに amplify/backend/hosting/S3AndCloudFront/template.json というファイルが作成されます。
このtemplate.jsonaws cloudformation のテンプレートファイルです。
ここに cloudfront + s3 でのホスティング環境に関する設定情報が記載されています。
このtemplate.jsonを編集してカスタムドメインを紐づけていきます。

もし、cloudformation を詳しく理解してからやりたい場合は、以下の記事が参考になります。
https://dev.classmethod.jp/articles/cloudformation-beginner01/
(あと chat gpt4 に聞くのもありです。なんでも質問していいので理解が早まります。)

完成した template.json

まずは完成された template.json を貼り付けます。(長いので折りたたみました)

最終的な `template.json`
template.json
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Description": "{\"createdOn\":\"Linux\",\"createdBy\":\"Amplify\",\"createdWith\":\"12.8.2\",\"stackType\":\"hosting-S3AndCloudFront\",\"metadata\":{}}",
  "Parameters": {
    "env": {
      "Type": "String"
    },
    "bucketName": {
      "Type": "String"
    },
    "customDomainName": {
      "Type": "String",
      "Default": "my-domain.com"
    },
    "acmCertificateArn": {
      "Type": "String",
      "Default": "arn:aws:acm:us-east-1:1234567890:certificate/hoge-huga"
    },
    "hostedZoneId": {
      "Type": "String",
      "Default": "Z0ABCDEFGHIG"
    }
  },
  "Conditions": {
    "ShouldNotCreateEnvResources": {
      "Fn::Equals": [
        {
          "Ref": "env"
        },
        "NONE"
      ]
    },
    "IsProdEnv": {
      "Fn::Equals": [
        {
          "Ref": "env"
        },
        "main"
      ]
    }
  },
  "Resources": {
    "S3Bucket": {
      "Type": "AWS::S3::Bucket",
      "DeletionPolicy": "Retain",
      "Properties": {
        "BucketName": {
          "Fn::If": [
            "ShouldNotCreateEnvResources",
            {
              "Ref": "bucketName"
            },
            {
              "Fn::Join": [
                "",
                [
                  {
                    "Ref": "bucketName"
                  },
                  "-",
                  {
                    "Ref": "env"
                  }
                ]
              ]
            }
          ]
        },
        "AccessControl": "Private",
        "WebsiteConfiguration": {
          "IndexDocument": "index.html",
          "ErrorDocument": "index.html"
        },
        "CorsConfiguration": {
          "CorsRules": [
            {
              "AllowedHeaders": [
                "Authorization",
                "Content-Length"
              ],
              "AllowedMethods": [
                "GET"
              ],
              "AllowedOrigins": [
                "*"
              ],
              "MaxAge": 3000
            }
          ]
        },
        "BucketEncryption": {
          "ServerSideEncryptionConfiguration": [
            {
              "ServerSideEncryptionByDefault": {
                "SSEAlgorithm": "AES256"
              }
            }
          ]
        }
      }
    },
    "PrivateBucketPolicy": {
      "Type": "AWS::S3::BucketPolicy",
      "DependsOn": "OriginAccessIdentity",
      "Properties": {
        "PolicyDocument": {
          "Id": "MyPolicy",
          "Version": "2012-10-17",
          "Statement": [
            {
              "Sid": "APIReadForGetBucketObjects",
              "Effect": "Allow",
              "Principal": {
                "CanonicalUser": {
                  "Fn::GetAtt": [
                    "OriginAccessIdentity",
                    "S3CanonicalUserId"
                  ]
                }
              },
              "Action": "s3:GetObject",
              "Resource": {
                "Fn::Join": [
                  "",
                  [
                    "arn:aws:s3:::",
                    {
                      "Ref": "S3Bucket"
                    },
                    "/*"
                  ]
                ]
              }
            }
          ]
        },
        "Bucket": {
          "Ref": "S3Bucket"
        }
      }
    },
    "OriginAccessIdentity": {
      "Type": "AWS::CloudFront::CloudFrontOriginAccessIdentity",
      "Properties": {
        "CloudFrontOriginAccessIdentityConfig": {
          "Comment": "CloudFrontOriginAccessIdentityConfig"
        }
      }
    },
    "AddsIndexHtmlFunction": {
      "Type": "AWS::CloudFront::Function",
      "Properties": {
        "Name": {
          "Fn::Sub": "FrontEndEdgeFunction-${env}"
        },
        "AutoPublish": "true",
        "FunctionConfig": {
          "Comment": "Setting to add \"index.html\" to the access URL.",
          "Runtime": "cloudfront-js-1.0"
        },
        "FunctionCode": "function handler(event) { var request = event.request; var uri = request.uri; if (uri.endsWith('/')) { request.uri += 'index.html'; } else if (!uri.includes('.')) { request.uri += '/index.html'; } return request; }"
      }
    },
    "CloudFrontDistribution": {
      "Type": "AWS::CloudFront::Distribution",
      "DependsOn": [
        "S3Bucket",
        "OriginAccessIdentity"
      ],
      "Properties": {
        "DistributionConfig": {
          "Aliases": [
            {
              "Fn::If": [
                "IsProdEnv",
                {
                  "Ref": "customDomainName"
                },
                {
                  "Fn::Sub": "${env}.${customDomainName}"
                }
              ]
            },
            {
              "Fn::If": [
                "IsProdEnv",
                {
                  "Fn::Sub": "www.${customDomainName}"
                },
                {
                  "Fn::Sub": "www.${env}.${customDomainName}"
                }
              ]
            }
          ],
          "ViewerCertificate": {
            "AcmCertificateArn": {
              "Ref": "acmCertificateArn"
            },
            "SslSupportMethod": "sni-only",
            "MinimumProtocolVersion": "TLSv1.2_2018"
          },
          "Comment": {
            "Fn::Sub": "${bucketName}-${env}"
          },
          "HttpVersion": "http2",
          "Origins": [
            {
              "DomainName": {
                "Fn::GetAtt": [
                  "S3Bucket",
                  "RegionalDomainName"
                ]
              },
              "Id": "hostingS3Bucket",
              "S3OriginConfig": {
                "OriginAccessIdentity": {
                  "Fn::Join": [
                    "",
                    [
                      "origin-access-identity/cloudfront/",
                      {
                        "Ref": "OriginAccessIdentity"
                      }
                    ]
                  ]
                }
              }
            }
          ],
          "Enabled": "true",
          "DefaultCacheBehavior": {
            "AllowedMethods": [
              "DELETE",
              "GET",
              "HEAD",
              "OPTIONS",
              "PATCH",
              "POST",
              "PUT"
            ],
            "TargetOriginId": "hostingS3Bucket",
            "ForwardedValues": {
              "QueryString": "false"
            },
            "ViewerProtocolPolicy": "redirect-to-https",
            "DefaultTTL": 86400,
            "MaxTTL": 31536000,
            "MinTTL": 60,
            "Compress": true,
            "FunctionAssociations": [
              {
                "EventType": "viewer-request",
                "FunctionARN": {
                  "Fn::GetAtt": [
                    "AddsIndexHtmlFunction",
                    "FunctionARN"
                  ]
                }
              }
            ]
          },
          "DefaultRootObject": "index.html",
          "CustomErrorResponses": [
            {
              "ErrorCachingMinTTL": 300,
              "ErrorCode": 400,
              "ResponseCode": 200,
              "ResponsePagePath": "/404/index.html"
            },
            {
              "ErrorCachingMinTTL": 300,
              "ErrorCode": 403,
              "ResponseCode": 200,
              "ResponsePagePath": "/404/index.html"
            },
            {
              "ErrorCachingMinTTL": 300,
              "ErrorCode": 404,
              "ResponseCode": 200,
              "ResponsePagePath": "/404/index.html"
            }
          ]
        }
      }
    },
    "DNSRecord": {
      "Type": "AWS::Route53::RecordSet",
      "Properties": {
        "HostedZoneId": {
          "Ref": "hostedZoneId"
        },
        "Name": {
          "Fn::If": [
            "IsProdEnv",
            {
              "Fn::Sub": "${customDomainName}."
            },
            {
              "Fn::Sub": "${env}.${customDomainName}."
            }
          ]
        },
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": {
            "Fn::GetAtt": [
              "CloudFrontDistribution",
              "DomainName"
            ]
          }
        }
      }
    },
    "DNSRecordWWW": {
      "Type": "AWS::Route53::RecordSet",
      "Properties": {
        "HostedZoneId": {
          "Ref": "hostedZoneId"
        },
        "Name": {
          "Fn::If": [
            "IsProdEnv",
            {
              "Fn::Sub": "www.${customDomainName}."
            },
            {
              "Fn::Sub": "www.${env}.${customDomainName}."
            }
          ]
        },
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": {
            "Fn::GetAtt": [
              "CloudFrontDistribution",
              "DomainName"
            ]
          }
        }
      }
    }
  },
  "Outputs": {
    "Region": {
      "Value": {
        "Ref": "AWS::Region"
      }
    },
    "HostingBucketName": {
      "Description": "Hosting bucket name",
      "Value": {
        "Ref": "S3Bucket"
      }
    },
    "WebsiteURL": {
      "Value": {
        "Fn::GetAtt": [
          "S3Bucket",
          "WebsiteURL"
        ]
      },
      "Description": "URL for website hosted on S3"
    },
    "S3BucketSecureURL": {
      "Value": {
        "Fn::Join": [
          "",
          [
            "https://",
            {
              "Fn::GetAtt": [
                "S3Bucket",
                "DomainName"
              ]
            }
          ]
        ]
      },
      "Description": "Name of S3 bucket to hold website content"
    },
    "CloudFrontDistributionID": {
      "Value": {
        "Ref": "CloudFrontDistribution"
      }
    },
    "CloudFrontDomainName": {
      "Value": {
        "Fn::GetAtt": [
          "CloudFrontDistribution",
          "DomainName"
        ]
      }
    },
    "CloudFrontSecureURL": {
      "Value": {
        "Fn::If": [
          "IsProdEnv",
          {
            "Fn::Sub": "https://${customDomainName}"
          },
          {
            "Fn::Sub": "https://${env}.${customDomainName}"
          }
        ]
      }
    },
    "CloudFrontOriginAccessIdentity": {
      "Value": {
        "Ref": "OriginAccessIdentity"
      }
    }
  }
}

では、デフォルトのコードとの変更点を 1 つずつ見ていきます。

1.Parameters の設定

カスタムドメイン名、ACM 証明書の ARN、Route 53 のホストゾーン ID をパラメータとして定義します。これにより、複数回のハードコーディングを避け、変更が容易になります。

  • customDomainName: Route53 で取得済みのカスタムドメイン名

  • acmCertificateArn: ACM で取得した証明書の ARN。この証明書は cloudfront に紐づけるため us-east-1 リージョンで作成する必要があります。
    https://docs.aws.amazon.com/ja_jp/acm/latest/userguide/acm-regions.html

    Amazon CloudFront で ACM 証明書を使用するには、米国東部 (バージニア北部) リージョン の証明書をリクエスト (またはインポート) していることを確認します。CloudFront ディストリビューションに関連づけられたこのリージョンの ACM 証明書は、このディストリビューションに設定されたすべての地域に分配されます。

  • hostedZoneId: Route53 で作成したホストゾーンの ID

実際に template.json に記載すると以下のようになります。

template.json
  "Parameters": {
    "env": {
      "Type": "String"
    },
    "bucketName": {
      "Type": "String"
    },
+    "customDomainName": {
+      "Type": "String",
+      "Default": "my-domain.com"
+    },
+    "acmCertificateArn": {
+      "Type": "String",
+      "Default": "arn:aws:acm:us-east-1:1234567890:certificate/hogehoge"
+    },
+    "hostedZoneId": {
+      "Type": "String",
+      "Default": "hogehogeId"
+    }
  },

2.Conditions の設定

次に ConditionsIsProdEnvという条件式を追加します。
このIsProdEnvは名前の通り、本番環境かどうかを判定するための条件式です。
IsProdEnvを使って、Cloudfront に紐づけるカスタムドメイン名を本番環境(my-domain.com)と開発環境(${dev}.my-domain.com)で分けるために使います。

template.json
  "Conditions": {
    "IsProdEnv": {
      "Fn::Equals": [
        {
          "Ref": "env"
        },
        "main"
      ]
    }
  },

3.Resources の設定

次に Resources で設定する項目を記載してきます。
このセクションは設定項目が多いので、用途ごとに分けて説明していきます。

S3Bucket の AccessControl を設定する

まず初めに、S3BucketPropertiesAccessControlを追加します。

こちら、どういう理由で設定したのか忘れてしまったのですが、これがないとamplify publishでデプロイしたときにエラーになってしまったので、入れてます。。。

template.json
  "Resources": {
    "S3Bucket": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
+        "AccessControl": "Private",
      }
    }
  }

公式では、「ほとんどのケースで設定が必要ないはずなので使うことは非推奨」と言われてるのでここは要調査項目ではあります。(あとで確認してみて理解ができたら記事を更新するかもです)
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-s3-bucket.html#cfn-s3-bucket-accesscontrol

cloudfront にカスタムドメインを紐づける

次に本題の Cloudfront にカスタムドメインを紐づける設定をしていきます。
Cloudfront にカスタムドメインを紐づけるには以下の 2 つの設定が必要です。

  • AWS::CloudFront::DistributionAliasesにカスタムドメイン名を設定する
  • AWS::Route53::RecordSetAliasTargetに Cloudfront のドメイン名を設定する
template.json
  "Resources": {
      "CloudFrontDistribution": {
      "Type": "AWS::CloudFront::Distribution",
      "DependsOn": [
        "S3Bucket",
        "OriginAccessIdentity"
      ],
      "Properties": {
        "DistributionConfig": {
+          "Aliases": [
+            {
+              "Fn::If": [
+                "IsProdEnv",
+                {
+                  "Ref": "customDomainName"
+                },
+                {
+                  "Fn::Sub": "${env}.${customDomainName}"
+                }
+              ]
+            }
+          ],
+          "ViewerCertificate": {
+            "AcmCertificateArn": {
+              "Ref": "acmCertificateArn"
+            },
+            "SslSupportMethod": "sni-only",
+            "MinimumProtocolVersion": "TLSv1.2_2018"
+          },
        // その他の設定
        }
      }
    },
+    "DNSRecord": {
+      "Type": "AWS::Route53::RecordSet",
+      "Properties": {
+        "HostedZoneId": {
+          "Ref": "hostedZoneId"
+        },
+        "Name": {
+          "Fn::If": [
+            "IsProdEnv",
+            {
+              "Fn::Sub": "${customDomainName}."
+            },
+            {
+              "Fn::Sub": "${env}.${customDomainName}."
+            }
+          ]
+        },
+        "Type": "A",
+        "AliasTarget": {
+          "HostedZoneId": "Z2FDTNDATAQYW2",
+          "DNSName": {
+            "Fn::GetAtt": [
+              "CloudFrontDistribution",
+              "DomainName"
+            ]
+          }
+        }
+      }
+    },
  }

注目すべき部分は以下のコードです。

    {
        "Fn::If": [
            "IsProdEnv",
                {
                    "Ref": "customDomainName"
                },
                {
                    "Fn::Sub": "${env}.${customDomainName}"
                }
            ]
    }

このコードはIsProdEnvtrueの場合は${customDomainName}を、falseの場合は${env}.${customDomainName}を返すようになっています。
例えば、Parameters セクションで設定した customDomainNamemy-domain.comの場合は
本番環境ではmy-domain.com、開発環境では${env}.my-domain.comという値になります。

開発環境時の${env}には amplify env でチェックアウト中の env 名が入ります。
これによって、開発環境ごとにドメイン名を分けることができます。
ex) dev.my-domain.comstg.my-domain.commy-domain.com(本番環境)

以上の設定で、cloudfront にカスタムドメインを紐づけることができました。
が、しかし、個人的には追加で設定したい項目もあるので、ついでに紹介します。

Cloudfront のディストリビューションに説明をつける

1 つは AWS::CloudFront::DistributionCommentです。
ここには Cloudfront の ディストリビューションの説明に記載する内容を設定できます。
デフォルトでは空白になっており、いくつかの環境を立ち上げた際に、各ディストリビューション ID がどの環境に紐づいているのかわかりにくいので、ここに環境名を設定しておくとわかりやすいです。

template.json
  "Resources": {
      "CloudFrontDistribution": {
      "Type": "AWS::CloudFront::Distribution",
      "Properties": {
        "DistributionConfig": {
+          "Comment": {
+            "Fn::Sub": "${bucketName}-${env}"
+          },
        // その他の設定
        }
      }
    },
  }

私は${bucketName}-${env}という形式で設定しましたが、${env}.${customDomainName}にしてドメイン名を入れてもいいかもしれません。

cloudfront に www ドメイン名も紐づける

www をつけたドメイン名でもアクセスできるようにするために、www ドメイン名も cloudfront に紐づけます。
もし、amplify console hosting でカスタムドメインを紐づけていた場合は、www ドメイン名も自動で紐づけられていたと思いますので、amplify add hosting に移行する場合は、この設定も忘れずに行った方がよいかもしれません。でないと、すでに www でアクセスしていたユーザーが直 URL でアクセスした際に 404 エラーになる可能性があります。
設定方法は「cloudfront にカスタムドメインを紐づける」とほぼ同じで www ありの設定が追加で必要なくらいですです。
「cloudfront にカスタムドメインを紐づける」で紹介したコードに対して追加のコードを記載します。

template.json
  "Resources": {
      "CloudFrontDistribution": {
      "Type": "AWS::CloudFront::Distribution",
      "DependsOn": [
        "S3Bucket",
        "OriginAccessIdentity"
      ],
      "Properties": {
        "DistributionConfig": {
          "Aliases": [
            {
              "Fn::If": [
                "IsProdEnv",
                {
                  "Ref": "customDomainName"
                },
                {
                  "Fn::Sub": "${env}.${customDomainName}"
                }
              ]
            },
+            {
+              "Fn::If": [
+                "IsProdEnv",
+                {
+                  "Fn::Sub": "www.${customDomainName}"
+                },
+                {
+                  "Fn::Sub": "www.${env}.${customDomainName}"
+                }
+              ]
+            }
          ],
          "ViewerCertificate": {
            "AcmCertificateArn": {
              "Ref": "acmCertificateArn"
            },
            "SslSupportMethod": "sni-only",
            "MinimumProtocolVersion": "TLSv1.2_2018"
          },
        // その他の設定
        }
      }
    },
    "DNSRecord": {
      "Type": "AWS::Route53::RecordSet",
      "Properties": {
        "HostedZoneId": {
          "Ref": "hostedZoneId"
        },
        "Name": {
          "Fn::If": [
            "IsProdEnv",
            {
              "Fn::Sub": "${customDomainName}."
            },
            {
              "Fn::Sub": "${env}.${customDomainName}."
            }
          ]
        },
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": {
            "Fn::GetAtt": [
              "CloudFrontDistribution",
              "DomainName"
            ]
          }
        }
      }
    },
+    "DNSRecordWWW": {
+      "Type": "AWS::Route53::RecordSet",
+      "Properties": {
+        "HostedZoneId": {
+          "Ref": "hostedZoneId"
+        },
+        "Name": {
+          "Fn::If": [
+            "IsProdEnv",
+            {
+              "Fn::Sub": "www.${customDomainName}."
+            },
+            {
+              "Fn::Sub": "www.${env}.${customDomainName}."
+            }
+          ]
+        },
+        "Type": "A",
+        "AliasTarget": {
+          "HostedZoneId": "Z2FDTNDATAQYW2",
+          "DNSName": {
+            "Fn::GetAtt": [
+              "CloudFrontDistribution",
+              "DomainName"
+            ]
+          }
+        }
+      }
+    }
  }

これで、www ドメイン名でもアクセスできるようになりました。

Cloudfront Functions で、Next.js の static サイトへのリクエストを正常に処理する

ほぼ完成かと思いきや、もう 1 つ設定が必要でした。
私が扱っているフロントエンドの構成だと、my-domain.com/hoge にアクセスしてリロードした場合my-domain.com/の内容が表示されてしまいます。

現象の原因(Chat GPT に聞いた結果)は以下の通りです。
S3 のデフォルトの挙動: S3 バケットでの静的ウェブサイトホスティングでは、リクエストされたパスに対応するファイルが存在しない場合、デフォルトでは「インデックスドキュメント」(通常は index.html)が返されます。

CloudFront の挙動: CloudFront はリクエストを S3 バケットに転送しますが、リクエストされたパスに対応するオブジェクトが見つからない場合、S3 はインデックスドキュメントを返します。これが、/hoge にアクセスしリロードした場合に root/index.html の内容が表示される理由です。

また、私のアプリは Next.js の Static Export の機能を使って静的サイトとして構築しています。
更に next.config.jstrailingSlash: trueの設定を入れており、pages にあるファイルは全て パス/index.html というファイル名として build されます。

つまり、本来はmy-domain.com/hogeにアクセスした場合、my-domain.com/hoge/index.htmlが返されるべきということになります。
ということで ↑ このルーティングの設定をしていきます。

実際のコードは以下です。

template.json
  "Resources": {
+    "AddsIndexHtmlFunction": {
+      "Type": "AWS::CloudFront::Function",
+      "Properties": {
+        "Name": {
+          "Fn::Sub": "FrontEndEdgeFunction-${env}"
+        },
+        "AutoPublish": "true",
+        "FunctionConfig": {
+          "Comment": "Setting to add \"index.html\" to the access URL.",
+          "Runtime": "cloudfront-js-1.0"
+        },
+        "FunctionCode": "function handler(event) { var request = event.request; var uri = request.uri; if (uri.endsWith('/')) { request.uri += 'index.html'; } else if (!uri.includes('.')) { request.uri += '/index.html'; } return request; }"
+      }
    },
    "CloudFrontDistribution": {
      "Type": "AWS::CloudFront::Distribution",
      "DependsOn": [
        "S3Bucket",
        "OriginAccessIdentity"
      ],
      "Properties": {
        // その他の設定
        "DistributionConfig": {
          // その他の設定
          "DefaultCacheBehavior": {
            // その他の設定
+            "FunctionAssociations": [
+              {
+                "EventType": "viewer-request",
+                "FunctionARN": {
+                  "Fn::GetAtt": [
+                    "AddsIndexHtmlFunction",
+                    "FunctionARN"
+                  ]
+                }
+              }
+            ]
          },
        }
      }
    }
  }

解説

  • AWS::CloudFront::Function/hoge/ にアクセスした場合に /hoge/index.html を返す Cloudfront Function を作成
    実際のコードは以下の通りですが、json 形式の cloudformation テンプレートに記載するために1行にしています。

    // FunctionCodeの中身。実際はjson形式のテンプレートに記載するためにtemplate.jsonでは1行にしています。
    function handler(event) {
      var request = event.request
      var uri = request.uri
    
      if (uri.endsWith('/')) {
        request.uri += 'index.html'
      } else if (!uri.includes('.')) {
        request.uri += '/index.html'
      }
    
      return request
    }
    
  • 上記 Cloudfront FunctionCloudfrontDefaultCacheBehavior に紐づける

これで晴れて my-domain.com/hogeにアクセスした場合にmy-domain.com/hoge/index.htmlが返されるようになりました。

4.Outputs の設定

最後に、Outputs のカスタマイズになります。

amplify publish を実行した際や、amplify status を実行した際に、Cloudfront の URL がターミナルに表示されるのですが、デフォルトのままだと cloudfront の url(hoge.cloudfront.net)が表示されるので、カスタムドメインを表示するようにします。

template.json
  "Outputs": {
    "CloudFrontSecureURL": {
      "Value": {
-        "Fn::Join": [
-          "",
-          [
-            "https://",
-            {
-              "Fn::GetAtt": [
-                "CloudFrontDistribution",
-                "DomainName"
-              ]
-            }
-          ]
-        ]
+        "Fn::If": [
+          "IsProdEnv",
+          {
+            "Fn::Sub": "https://${customDomainName}"
+          },
+          {
+            "Fn::Sub": "https://${env}.${customDomainName}"
+          }
+        ]
      }
    },
  }

ここはあまり重要じゃないですがやっておくとたまに便利だったりしますね。

さて、フロントエンドにカスタムドメインを紐づけるための設定は完了です。
ですが、API にもカスタムドメインを紐づけたいという需要もあるんじゃないかと思います。
次のセクションでその手順をお伝えします。

REST API(Apigateway)にもカスタムドメインを紐づける

ここでは amplify add api で構築した REST API にカスタムドメインを紐づける方法を紹介します。
amplify add apiREST APIを選択すると、apigateway + lambda function でリソースが構築されてます。

apigateway にカスタムドメインを紐づけるには以下の 3 つの設定が必要です。

  • apigateway にカスタムドメインの証明書をつける
  • route 53 で A レコードを設定
  • apigateway でマッピング設定をする

この辺りは以下の記事が大変参考になりましたので、詳しくはこちらの記事の解説を参照してみてください 🙇‍♂️
https://zenn.dev/tatsurom/articles/api-gateway-domain-mapping

私の方からは上記で設定したカスタムドメインをフロントエンドから利用する手順を紹介します。

amplify で REST API を構築した場合、大体の方は API へのアクセスを以下のようなコードで行っていると思います。

import { API } from 'aws-amplify'

const getHoge = async () => {
  return API.get('myApiName', '/hoge')
}

デフォルトの設定のままだと、内部でリクエストに使われる URL は以下のよう apigateway の URL がそのまま使われています。

https://hogehoge.execute-api.ap-northeast-1.amazonaws.com/dev/hoge

このリクエスト URL を先ほど apigateway に設定したカスタムドメインにするには
Amplify.configure(awsExports) している部分で rest api の endpoint を以下のように置き換えます。

import { Amplify } from 'aws-amplify'

Amplify.configure({
  ...awsExports,
  aws_cloud_logic_custom: awsExports.aws_cloud_logic_custom.map((item) => {
    if (item.name === 'myApiName') {
      return {
        ...item,
        endpoint: 'api.my-domain.com',
      }
    }

    return item
  }),
})

こうすることでフロントエンドから aws-amplifyライブラリのコードを使って API アクセスする際に、api.my-domain.comが使われるようになります。

まとめ

この記事では、amplify cli で構築した環境にカスタムドメインを紐づける方法を紹介しました。
自分の備忘録やだれかの参考になれば幸いです。

Discussion