Skip to content

StaticSite Stack

The StaticSite class is a CDK stack for deploying static websites with authentication, scheduled rebuilds, and serverless serving.

Overview

StaticSite deploys a static website using:

  • S3 for storing built content
  • Lambda (container-image) for building the site on a schedule
  • Lambda (container-image) for serving files with cognito-auth authorization
  • ALB with HTTPS and Cognito authentication
  • EventBridge for scheduled rebuilds
  • WAF integration

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│                         Static Site Stack                              │
│                                                                        │
│  ┌────────┐    ┌─────┐    ┌──────────────┐    ┌───────────────────┐  │
│  │Route53 │───▶│ ALB │───▶│ Cognito Auth │───▶│   Serve Lambda    │  │
│  │A Record│    │     │    │ (if enabled) │    │  (container-image) │  │
│  └────────┘    │ WAF │    └──────────────┘    │                   │  │
│                └─────┘                         │  • cognito-auth   │  │
│                                                │  • /.auth/user    │  │
│                                                │  • authZ check    │  │
│                                                │  • S3 proxy       │  │
│                                                └────────┬──────────┘  │
│                                                         │              │
│                                                         ▼              │
│  ┌───────────────┐    ┌───────────────────┐    ┌───────────────────┐ │
│  │  EventBridge  │───▶│   Build Lambda    │───▶│    S3 Bucket      │ │
│  │  (schedule)   │    │  (container-image) │    │  (static files)   │ │
│  └───────────────┘    └───────────────────┘    └───────────────────┘ │
│                               ▲                                        │
│  ┌───────────────────────────┐│                                       │
│  │ Custom Resource            ││                                       │
│  │ (auto-invoke on deploy)   │┘                                       │
│  └───────────────────────────┘                                        │
│                                                                        │
│  ┌────────────────────────────────────────────────────────────────┐   │
│  │ Shared Infrastructure (from BaseWebStack)                       │   │
│  │ • Route53 subdomain hosted zone + NS delegation                │   │
│  │ • ACM certificate (DNS validated)                               │   │
│  │ • ACM cleanup Lambda (Custom Resource)                          │   │
│  └────────────────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────────────┘

On deploy, a Custom Resource auto-invokes the build Lambda so the site is immediately populated.

StaticSite

gds_idea_cdk_constructs.static_site.stack.StaticSite

Bases: BaseWebStack

A static site stack served by Lambda from S3 with ALB and optional auth.

Deploys a static website using: - S3 bucket for built content - Serve Lambda that proxies requests from ALB to S3 (with optional authZ) - Build Lambda (container-image) that runs the site build and uploads to S3 - ALB with HTTPS and optional Cognito authentication - EventBridge schedule for periodic rebuilds (optional) - Custom Resource to auto-invoke build on deploy

Source code in src/gds_idea_cdk_constructs/static_site/stack.py
class StaticSite(BaseWebStack):
    """A static site stack served by Lambda from S3 with ALB and optional auth.

    Deploys a static website using:
    - S3 bucket for built content
    - Serve Lambda that proxies requests from ALB to S3 (with optional authZ)
    - Build Lambda (container-image) that runs the site build and uploads to S3
    - ALB with HTTPS and optional Cognito authentication
    - EventBridge schedule for periodic rebuilds (optional)
    - Custom Resource to auto-invoke build on deploy
    """

    def __init__(
        self,
        scope: Construct,
        deployment_config: DeploymentConfig,
        app_config: AppConfig,
        authentication: AuthType = AuthType.INTERNAL_ACCESS,
        docker_context_path: str = ".",
        dockerfile_path: str = "site_src/Dockerfile",
        static_site_props: StaticSiteProperties | None = None,
        task_role: iam.Role | None = None,
        disable_waf: bool = False,
    ) -> None:
        """Initialize a StaticSite stack.

        Args:
            scope: The CDK app or stack to create this stack within.
            deployment_config: Environment-specific configuration including VPC,
                domain name, and AWS resource identifiers.
            app_config: Application configuration including name and framework.
            authentication: Authentication strategy to use. Defaults to
                AuthType.INTERNAL_ACCESS.
            docker_context_path: Path to the Docker build context directory
                containing the site source and Dockerfile.
            dockerfile_path: Path to the Dockerfile relative to docker_context_path.
                Defaults to "site_src/Dockerfile".
            static_site_props: Configuration for build and serve behaviour.
                Required — must provide at minimum a build_command.
            task_role: Custom IAM role for the Lambda functions. If None, a
                role will be created with appropriate permissions.
            disable_waf: Disable WAF association with the ALB. Defaults to False.

        Example:
            Basic usage with internal access authentication::

                from aws_cdk import Duration, aws_events as events

                app = App()
                deployment_config = DeploymentConfig(cdk_env)
                app_config = AppConfig(app_name="my-docs", framework="static")

                StaticSite(
                    app,
                    deployment_config,
                    app_config,
                    authentication=AuthType.INTERNAL_ACCESS,
                    docker_context_path="site_src",
                    dockerfile_path="site_src/Dockerfile",
                    static_site_props=StaticSiteProperties(
                        build_command="npx @11ty/eleventy --output=/tmp/_site",
                        build_schedule=events.Schedule.rate(Duration.hours(6)),
                    ),
                )
        """
        if static_site_props is None:
            raise ValueError("static_site_props is required for StaticSite")

        super().__init__(scope, deployment_config, app_config, authentication)

        self.static_site_props = static_site_props

        # Create task role for Lambda functions
        if task_role:
            self.task_role = task_role
            self._auth_strategy.configure_role_permissions(self.task_role)
        else:
            self.task_role = iam.Role(
                self,
                "TaskRole",
                assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
                managed_policies=[
                    iam.ManagedPolicy.from_aws_managed_policy_name(
                        "service-role/AWSLambdaBasicExecutionRole"
                    ),
                ],
            )
            self._auth_strategy.configure_role_permissions(self.task_role)

        # Let users assume the role if we are deploying in dev.
        if self.deployment_config.environment == DeploymentEnvironment.DEVELOPMENT:
            self._add_assume_policy_for_dev()

        logger.info(
            f"Creating static site: {self.app_name} "
            f"with authentication: {authentication}"
        )
        logger.info(f"Domain: {self.alb_domain_name}")

        # Orchestrate resource creation
        self._import_existing_resources()
        self._setup_dns_and_certificate()
        self._setup_acm_clean_up()
        self._setup_content_bucket()
        self._setup_serve_lambda()
        self._setup_build_lambda(docker_context_path, dockerfile_path)
        self._setup_load_balancer()
        self._setup_dns_record()
        self._setup_build_trigger()
        self._setup_auto_invoke()

        if disable_waf:
            logging.warning(
                "WAF is disabled. This should only be used for short-term debugging. "
                "Never use in production."
            )
        else:
            self._associate_waf()

        self._create_outputs()

    def _setup_content_bucket(self) -> None:
        """Create S3 bucket for built static site content."""
        bucket_name = f"cdk-static-{self.app_name}.{self.deployment_config.domain_name}"
        self.content_bucket = s3.Bucket(
            self,
            "ContentBucket",
            bucket_name=bucket_name,
            removal_policy=RemovalPolicy.DESTROY,
            auto_delete_objects=True,
            block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
            encryption=s3.BucketEncryption.S3_MANAGED,
        )

    def _setup_serve_lambda(self) -> None:
        """Create Lambda function that serves static files from S3."""
        serve_handler_path = str(self._get_lambda_handlers_path() / "serve")

        environment = {
            "CONTENT_BUCKET": self.content_bucket.bucket_name,
            "INDEX_DOCUMENT": self.static_site_props.index_document,
            **self._auth_strategy.get_environment_variables(),
        }
        if self.static_site_props.error_document:
            environment["ERROR_DOCUMENT"] = self.static_site_props.error_document

        self.serve_lambda = _lambda.Function(
            self,
            "ServeLambda",
            runtime=_lambda.Runtime.PYTHON_3_12,
            handler="handler.handler",
            code=_lambda.Code.from_asset(
                serve_handler_path,
                bundling=BundlingOptions(
                    image=_lambda.Runtime.PYTHON_3_12.bundling_image,
                    local=_LocalPipBundling(serve_handler_path),
                    command=["echo", "Docker fallback not implemented"],
                ),
            ),
            memory_size=self.static_site_props.serve_memory_size,
            timeout=Duration.seconds(30),
            role=self.task_role,
            environment=environment,
        )

        # Grant read access to content bucket
        self.content_bucket.grant_read(self.serve_lambda)

    def _setup_build_lambda(
        self, docker_context_path: str, dockerfile_path: str
    ) -> None:
        """Create container-image Lambda for building the static site.

        The build Lambda uses its own auto-generated execution role (not the
        shared task_role) because it only needs S3 write access — it does not
        need auth strategy permissions or dev assume-role capability.
        """
        # Build the Docker image for the build Lambda
        build_image = DockerImageAsset(
            self,
            "BuildImage",
            directory=docker_context_path,
            file=dockerfile_path,
            platform=Platform.LINUX_AMD64,
            target="build",
        )

        # Store image tag for use in auto-invoke trigger
        self._build_image_tag = build_image.image_tag

        environment = {
            "CONTENT_BUCKET": self.content_bucket.bucket_name,
            "BUILD_COMMAND": self.static_site_props.build_command,
            "BUILD_OUTPUT_DIR": self.static_site_props.build_output_dir,
            "CLEAN_ON_BUILD": (
                "true" if self.static_site_props.clean_on_build else "false"
            ),
            "KEEP_PREFIXES": ",".join(self.static_site_props.keep_prefixes),
            # Lambda filesystem is read-only except /tmp
            "HOME": "/tmp",
            "NPM_CONFIG_CACHE": "/tmp/.npm",
            **self.static_site_props.build_environment_variables,
        }

        self.build_lambda = _lambda.DockerImageFunction(
            self,
            "BuildLambda",
            code=_lambda.DockerImageCode.from_ecr(
                repository=build_image.repository,
                tag_or_digest=build_image.image_tag,
            ),
            memory_size=self.static_site_props.build_memory_size,
            timeout=Duration.seconds(self.static_site_props.build_timeout),
            environment=environment,
        )

        # Grant write access to content bucket
        self.content_bucket.grant_read_write(self.build_lambda)

    def _setup_load_balancer(self) -> None:
        """Create Lambda target group and set up ALB with listeners."""
        # Lambda target for ALB
        self.target_group = elbv2.ApplicationTargetGroup(
            self,
            "TargetGroup",
            vpc=self.vpc,
            target_type=elbv2.TargetType.LAMBDA,
            targets=[elbv2_targets.LambdaTarget(self.serve_lambda)],
            health_check=elbv2.HealthCheck(
                enabled=True,
                path="/health",
                healthy_http_codes="200",
            ),
        )

        self._setup_alb_and_listeners(self.target_group)

    def _setup_build_trigger(self) -> None:
        """Create EventBridge schedule rule if a schedule is configured."""
        if not self.static_site_props.build_schedule:
            return

        self.build_schedule_rule = events.Rule(
            self,
            "BuildScheduleRule",
            schedule=self.static_site_props.build_schedule,
            description=f"Scheduled rebuild for {self.app_name} static site",
        )
        self.build_schedule_rule.add_target(
            events_targets.LambdaFunction(self.build_lambda)
        )

    def _setup_auto_invoke(self) -> None:
        """Create Custom Resource to invoke build Lambda on deploy."""
        invoke_fn = _lambda.Function(
            self,
            "BuildInvokerFunction",
            runtime=_lambda.Runtime.PYTHON_3_12,
            handler="index.handler",
            timeout=Duration.minutes(10),
            code=_lambda.Code.from_inline(self._get_invoke_handler_code()),
            initial_policy=[
                iam.PolicyStatement(
                    actions=["lambda:InvokeFunction"],
                    resources=[self.build_lambda.function_arn],
                )
            ],
        )

        invoke_provider = cr.Provider(
            self, "BuildInvokerProvider", on_event_handler=invoke_fn
        )

        # Include image tag so changes to site content trigger a rebuild
        CustomResource(
            self,
            "BuildInvokerResource",
            service_token=invoke_provider.service_token,
            properties={
                "FunctionName": self.build_lambda.function_name,
                "BuildCommand": self.static_site_props.build_command,
                "ImageTag": self._build_image_tag,
            },
        )

    def _create_outputs(self) -> None:
        """Create CloudFormation outputs."""
        CfnOutput(
            self,
            "ApplicationURL",
            value=f"https://{self.alb_domain_name}",
            description=f"Static site URL for {self.app_name}",
        )

        CfnOutput(
            self,
            "ContentBucketName",
            value=self.content_bucket.bucket_name,
            description="S3 bucket containing built static site content",
        )

        CfnOutput(
            self,
            "BuildLambdaArn",
            value=self.build_lambda.function_arn,
            description="ARN of the build Lambda function",
        )

        CfnOutput(
            self,
            "TaskRoleARN",
            value=self.task_role.role_arn,
            description="Role assumed by the serve Lambda. If DEV can be assumed",
        )

        self._auth_strategy.create_outputs()

    @staticmethod
    def _get_lambda_handlers_path():
        """Get the path to the Lambda handlers directory."""
        return Path(__file__).parent / "lambda_handlers"

    @staticmethod
    def _get_invoke_handler_code() -> str:
        """Return inline Lambda code for the build invoker Custom Resource."""
        return """
import json
import boto3

lambda_client = boto3.client("lambda")


def handler(event, context):
    print(f"Event: {json.dumps(event)}")
    request_type = event.get("RequestType")

    if request_type in ("Create", "Update"):
        function_name = event["ResourceProperties"]["FunctionName"]
        print(f"Invoking build Lambda: {function_name}")

        try:
            response = lambda_client.invoke(
                FunctionName=function_name,
                InvocationType="Event",  # Async invocation
            )
            print(f"Invoke response: {response['StatusCode']}")
        except Exception as e:
            print(f"WARNING: Failed to invoke build Lambda: {e}")
            # Don't fail the Custom Resource - site will be empty until
            # next scheduled build or manual invocation

    return {"PhysicalResourceId": "BuildInvoker"}
"""

__init__

__init__(
    scope: Construct,
    deployment_config: DeploymentConfig,
    app_config: AppConfig,
    authentication: AuthType = AuthType.INTERNAL_ACCESS,
    docker_context_path: str = ".",
    dockerfile_path: str = "site_src/Dockerfile",
    static_site_props: StaticSiteProperties | None = None,
    task_role: Role | None = None,
    disable_waf: bool = False,
) -> None

Initialize a StaticSite stack.

Parameters:

Name Type Description Default
scope Construct

The CDK app or stack to create this stack within.

required
deployment_config DeploymentConfig

Environment-specific configuration including VPC, domain name, and AWS resource identifiers.

required
app_config AppConfig

Application configuration including name and framework.

required
authentication AuthType

Authentication strategy to use. Defaults to AuthType.INTERNAL_ACCESS.

INTERNAL_ACCESS
docker_context_path str

Path to the Docker build context directory containing the site source and Dockerfile.

'.'
dockerfile_path str

Path to the Dockerfile relative to docker_context_path. Defaults to "site_src/Dockerfile".

'site_src/Dockerfile'
static_site_props StaticSiteProperties | None

Configuration for build and serve behaviour. Required — must provide at minimum a build_command.

None
task_role Role | None

Custom IAM role for the Lambda functions. If None, a role will be created with appropriate permissions.

None
disable_waf bool

Disable WAF association with the ALB. Defaults to False.

False
Example

Basic usage with internal access authentication::

from aws_cdk import Duration, aws_events as events

app = App()
deployment_config = DeploymentConfig(cdk_env)
app_config = AppConfig(app_name="my-docs", framework="static")

StaticSite(
    app,
    deployment_config,
    app_config,
    authentication=AuthType.INTERNAL_ACCESS,
    docker_context_path="site_src",
    dockerfile_path="site_src/Dockerfile",
    static_site_props=StaticSiteProperties(
        build_command="npx @11ty/eleventy --output=/tmp/_site",
        build_schedule=events.Schedule.rate(Duration.hours(6)),
    ),
)
Source code in src/gds_idea_cdk_constructs/static_site/stack.py
def __init__(
    self,
    scope: Construct,
    deployment_config: DeploymentConfig,
    app_config: AppConfig,
    authentication: AuthType = AuthType.INTERNAL_ACCESS,
    docker_context_path: str = ".",
    dockerfile_path: str = "site_src/Dockerfile",
    static_site_props: StaticSiteProperties | None = None,
    task_role: iam.Role | None = None,
    disable_waf: bool = False,
) -> None:
    """Initialize a StaticSite stack.

    Args:
        scope: The CDK app or stack to create this stack within.
        deployment_config: Environment-specific configuration including VPC,
            domain name, and AWS resource identifiers.
        app_config: Application configuration including name and framework.
        authentication: Authentication strategy to use. Defaults to
            AuthType.INTERNAL_ACCESS.
        docker_context_path: Path to the Docker build context directory
            containing the site source and Dockerfile.
        dockerfile_path: Path to the Dockerfile relative to docker_context_path.
            Defaults to "site_src/Dockerfile".
        static_site_props: Configuration for build and serve behaviour.
            Required — must provide at minimum a build_command.
        task_role: Custom IAM role for the Lambda functions. If None, a
            role will be created with appropriate permissions.
        disable_waf: Disable WAF association with the ALB. Defaults to False.

    Example:
        Basic usage with internal access authentication::

            from aws_cdk import Duration, aws_events as events

            app = App()
            deployment_config = DeploymentConfig(cdk_env)
            app_config = AppConfig(app_name="my-docs", framework="static")

            StaticSite(
                app,
                deployment_config,
                app_config,
                authentication=AuthType.INTERNAL_ACCESS,
                docker_context_path="site_src",
                dockerfile_path="site_src/Dockerfile",
                static_site_props=StaticSiteProperties(
                    build_command="npx @11ty/eleventy --output=/tmp/_site",
                    build_schedule=events.Schedule.rate(Duration.hours(6)),
                ),
            )
    """
    if static_site_props is None:
        raise ValueError("static_site_props is required for StaticSite")

    super().__init__(scope, deployment_config, app_config, authentication)

    self.static_site_props = static_site_props

    # Create task role for Lambda functions
    if task_role:
        self.task_role = task_role
        self._auth_strategy.configure_role_permissions(self.task_role)
    else:
        self.task_role = iam.Role(
            self,
            "TaskRole",
            assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
            managed_policies=[
                iam.ManagedPolicy.from_aws_managed_policy_name(
                    "service-role/AWSLambdaBasicExecutionRole"
                ),
            ],
        )
        self._auth_strategy.configure_role_permissions(self.task_role)

    # Let users assume the role if we are deploying in dev.
    if self.deployment_config.environment == DeploymentEnvironment.DEVELOPMENT:
        self._add_assume_policy_for_dev()

    logger.info(
        f"Creating static site: {self.app_name} "
        f"with authentication: {authentication}"
    )
    logger.info(f"Domain: {self.alb_domain_name}")

    # Orchestrate resource creation
    self._import_existing_resources()
    self._setup_dns_and_certificate()
    self._setup_acm_clean_up()
    self._setup_content_bucket()
    self._setup_serve_lambda()
    self._setup_build_lambda(docker_context_path, dockerfile_path)
    self._setup_load_balancer()
    self._setup_dns_record()
    self._setup_build_trigger()
    self._setup_auto_invoke()

    if disable_waf:
        logging.warning(
            "WAF is disabled. This should only be used for short-term debugging. "
            "Never use in production."
        )
    else:
        self._associate_waf()

    self._create_outputs()

StaticSiteProperties

gds_idea_cdk_constructs.static_site.props.StaticSiteProperties dataclass

Configuration properties for a StaticSite stack.

Controls build Lambda behaviour, schedule, and serve Lambda settings.

Source code in src/gds_idea_cdk_constructs/static_site/props.py
@dataclass
class StaticSiteProperties:
    """Configuration properties for a StaticSite stack.

    Controls build Lambda behaviour, schedule, and serve Lambda settings.
    """

    # Build configuration
    build_command: str
    """The shell command to run inside the build container (e.g. 'npx eleventy')."""

    build_output_dir: str = "/tmp/_site"
    """Directory containing built output. Must be under /tmp/ since Lambda
    filesystem is read-only. Defaults to '/tmp/_site'."""

    build_schedule: events.Schedule | None = None
    """EventBridge schedule for periodic rebuilds. Use events.Schedule.rate()
    or events.Schedule.cron(). If None, no schedule is created.

    Examples:
        events.Schedule.rate(Duration.hours(6))
        events.Schedule.cron(hour="6", minute="0")
    """

    build_timeout: int = 300
    """Lambda timeout in seconds for the build function (max 900)."""

    build_memory_size: int = 1024
    """Memory in MB allocated to the build Lambda."""

    build_environment_variables: dict[str, str] = field(default_factory=dict)
    """Additional environment variables passed to the build Lambda."""

    # Clean build configuration
    clean_on_build: bool = True
    """Remove stale files from S3 after build. Files uploaded by the current
    build are kept; all others are deleted unless protected by keep_prefixes.
    Set to False if external processes write to the same bucket."""

    keep_prefixes: list[str] = field(default_factory=list)
    """S3 key prefixes to never delete during cleanup. Useful when external
    processes (ETL, data pipelines) write to the same bucket under known
    prefixes. Only relevant when clean_on_build=True.
    Example: ['data/', 'uploads/']"""

    # Serve configuration
    serve_memory_size: int = 256
    """Memory in MB allocated to the serve Lambda."""

    index_document: str = "index.html"
    """Default document served for directory requests
    (e.g. '/' serves '/index.html')."""

    error_document: str | None = "404.html"
    """Document served for 404 responses. Set to None to return a generic error."""

build_command instance-attribute

build_command: str

The shell command to run inside the build container (e.g. 'npx eleventy').

build_output_dir class-attribute instance-attribute

build_output_dir: str = '/tmp/_site'

Directory containing built output. Must be under /tmp/ since Lambda filesystem is read-only. Defaults to '/tmp/_site'.

build_schedule class-attribute instance-attribute

build_schedule: Schedule | None = None

EventBridge schedule for periodic rebuilds. Use events.Schedule.rate() or events.Schedule.cron(). If None, no schedule is created.

Examples:

events.Schedule.rate(Duration.hours(6)) events.Schedule.cron(hour="6", minute="0")

build_timeout class-attribute instance-attribute

build_timeout: int = 300

Lambda timeout in seconds for the build function (max 900).

build_memory_size class-attribute instance-attribute

build_memory_size: int = 1024

Memory in MB allocated to the build Lambda.

build_environment_variables class-attribute instance-attribute

build_environment_variables: dict[str, str] = field(
    default_factory=dict
)

Additional environment variables passed to the build Lambda.

clean_on_build class-attribute instance-attribute

clean_on_build: bool = True

Remove stale files from S3 after build. Files uploaded by the current build are kept; all others are deleted unless protected by keep_prefixes. Set to False if external processes write to the same bucket.

keep_prefixes class-attribute instance-attribute

keep_prefixes: list[str] = field(default_factory=list)

S3 key prefixes to never delete during cleanup. Useful when external processes (ETL, data pipelines) write to the same bucket under known prefixes. Only relevant when clean_on_build=True. Example: ['data/', 'uploads/']

serve_memory_size class-attribute instance-attribute

serve_memory_size: int = 256

Memory in MB allocated to the serve Lambda.

index_document class-attribute instance-attribute

index_document: str = 'index.html'

Default document served for directory requests (e.g. '/' serves '/index.html').

error_document class-attribute instance-attribute

error_document: str | None = '404.html'

Document served for 404 responses. Set to None to return a generic error.

Usage Examples

Basic Example (Eleventy)

from aws_cdk import App, Duration, Environment, aws_events as events
from gds_idea_cdk_constructs import AppConfig, DeploymentConfig
from gds_idea_cdk_constructs.static_site import AuthType, StaticSite, StaticSiteProperties

app = App()
cdk_env = Environment(account="992382722318", region="eu-west-2")

StaticSite(
    app,
    DeploymentConfig(cdk_env),
    AppConfig(app_name="my-docs", framework="static"),
    authentication=AuthType.INTERNAL_ACCESS,
    docker_context_path="site_src",
    dockerfile_path="Dockerfile",
    static_site_props=StaticSiteProperties(
        build_command="npx @11ty/eleventy --output=/tmp/_site",
        build_output_dir="/tmp/_site",
        build_schedule=events.Schedule.rate(Duration.hours(6)),
    ),
)

app.synth()

Public Site (No Authentication)

StaticSite(
    app,
    DeploymentConfig(cdk_env),
    AppConfig(app_name="public-docs", framework="static"),
    authentication=AuthType.NONE,
    docker_context_path="site_src",
    dockerfile_path="Dockerfile",
    static_site_props=StaticSiteProperties(
        build_command="npx @11ty/eleventy --output=/tmp/_site",
        build_output_dir="/tmp/_site",
    ),
)

MkDocs Site (Python)

StaticSite(
    app,
    DeploymentConfig(cdk_env),
    AppConfig(app_name="team-docs", framework="static"),
    authentication=AuthType.INTERNAL_ACCESS,
    docker_context_path="docs_src",
    dockerfile_path="Dockerfile",
    static_site_props=StaticSiteProperties(
        build_command="mkdocs build --site-dir /tmp/_site",
        build_output_dir="/tmp/_site",
        build_schedule=events.Schedule.rate(Duration.hours(6)),
    ),
)

Dockerfile Structure

The static site uses a multi-stage Dockerfile shared between the dev container and the build Lambda:

# Base stage: install build tools and dependencies
FROM node:20-slim AS base
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

# Development: used by devcontainer for local development
FROM base AS development
EXPOSE 8080
CMD ["npx", "@11ty/eleventy", "--serve", "--port=8080"]

# Build Lambda: runs the build and uploads to S3
FROM public.ecr.aws/lambda/python:3.12 AS build
RUN dnf install -y nodejs20 npm
COPY --from=base /app /var/task/site
COPY handler.py /var/task/
WORKDIR /var/task/site
CMD ["handler.handler"]

Lambda filesystem is read-only

Lambda can only write to /tmp. Always direct build output to /tmp/ (e.g., --output=/tmp/_site) and set build_output_dir to the same path.

Build Handler

The handler.py in your project is a construct-managed file. It:

  1. Runs the configured BUILD_COMMAND via subprocess
  2. Walks the BUILD_OUTPUT_DIR directory
  3. Uploads all files to S3 with correct Content-Type headers

You don't need to write this file — it's provided by the construct (managed by idea-app).

User Claims Endpoint

When authentication is enabled, the serve Lambda exposes a /.auth/user endpoint that returns the authenticated user's claims as JSON.

Request

GET /.auth/user

Response

{
  "sub": "abc123",
  "email": "user@example.gov.uk",
  "name": "Jane Smith",
  "given_name": "Jane",
  "family_name": "Smith",
  "groups": ["gds-idea", "my-app-admins"],
  "is_admin": true,
  "email_domain": "example.gov.uk",
  "email_verified": true
}

Usage in Static Site JavaScript

<script>
  fetch('/.auth/user')
    .then(r => r.ok ? r.json() : null)
    .then(user => {
      if (user) {
        document.getElementById('user-email').textContent = user.email;
        document.getElementById('user-name').textContent = user.name;
      }
    });
</script>

This endpoint:

  • Returns user claims via cognito-auth (includes groups from the Cognito access token)
  • Sets Cache-Control: no-store (never cached)
  • Returns 404 for AuthType.NONE (no authentication configured)
  • Does not require an additional authentication step (ALB already authenticated the user)

Clean Builds

By default, the build Lambda removes stale files from S3 after uploading new content. This ensures that deleted or renamed pages don't linger.

Default behaviour (clean_on_build=True)

After uploading the build output, any S3 objects that were not part of the current build are deleted. New content is uploaded first, so there is no downtime.

Protecting external files (keep_prefixes)

If external processes (ETL pipelines, data uploads) write to the same bucket, protect those files with keep_prefixes:

StaticSiteProperties(
    build_command="npx @11ty/eleventy --output=/tmp/_site",
    build_output_dir="/tmp/_site",
    keep_prefixes=["data/", "uploads/"],
)

Files under data/ and uploads/ will never be deleted during cleanup.

Disabling cleanup entirely

If you don't want the build to delete anything:

StaticSiteProperties(
    build_command="npx @11ty/eleventy --output=/tmp/_site",
    build_output_dir="/tmp/_site",
    clean_on_build=False,
)

Old files will persist until manually removed.

Caching

The serve Lambda uses two complementary caching strategies to minimise latency and reduce costs.

HTTP Cache-Control headers (browser-side)

Response headers tell the browser what to cache:

File type Header Behaviour
HTML (.html) max-age=0, must-revalidate Always revalidates with server
Hashed assets (.js, .css, fonts) max-age=31536000, immutable Cached for 1 year
Other files max-age=3600 Cached for 1 hour

This reduces Lambda invocations per user — the browser serves cached assets locally.

In-memory LRU cache (Lambda-side)

S3 file reads are cached in Lambda memory using functools.lru_cache. Once a file is read from S3, subsequent requests within the same warm Lambda execution environment are served from memory — no S3 API call.

Configurable via CACHE_MAX_SIZE environment variable (default: 128 files).

Trade-offs:

  • After a rebuild, previously-cached files may serve stale content until the Lambda environment recycles (typically seconds to minutes)
  • Missing files (404s) are not cached — every request for a missing file re-checks S3, so a file that appears after a delayed build is served on the very next request
  • Each cached file consumes Lambda memory (bounded by maxsize)

For full rationale, see ADR-001: Use LRU cache for S3 reads.

Created Resources

Resource Purpose
S3 Bucket Stores built static site content
Serve Lambda Proxies files from S3, handles authZ and /.auth/user
Build Lambda (container-image) Runs the site build and uploads output to S3
Application Load Balancer HTTPS termination, Cognito auth action
Target Group (Lambda) Routes ALB traffic to the serve Lambda
EventBridge Rule Triggers scheduled rebuilds (if configured)
Custom Resource Auto-invokes build on deploy
Route53 Hosted Zone Subdomain DNS
ACM Certificate TLS certificate with DNS validation
WAF Association Security (enabled by default)

CloudFormation Outputs

  • ApplicationURL — HTTPS URL for the static site
  • ContentBucketName — S3 bucket name (for manual uploads or debugging)
  • BuildLambdaArn — ARN of the build Lambda (for manual invocation)
  • TaskRoleARN — IAM role ARN (can be assumed in DEV)
  • CognitoClientId (Cognito auth only) — OAuth2 client ID