This post describes how I used AWS CDK (Python) to create a serverless Statamic app.
I don’t include a good store (RDS), or a queue worker, or the ability to run artisan. These are all coming soon in anohter post!
“Dynamic” content is served by a Lambda function (using Bref) over an API Gateway HTTPApi, static content (images, css & js) is served by Cloudfront.
Prerequisites
- A decent terminal (I am using Warp)
- A decent IDE/Text Editor (I am using VSCode + CoPilot)
- You have AWS CDK installed
- You have Docker desktop installed and running
- You have Laravel (valet or sail)
- PHP and a little Python experience
- You have an AWS account you can mess around with
Steps
Install Statamic and kick off a new project:
composer global require statamic/cli
statamic new laravel-statamic-serverless
Follow prompts:
1: Starter Kit # Because blank slates are no fun
Name of starter kit: statamic/starter-kit-cool-writings
Create Superuser: no # we're good for now
It looks like this:
We’re then going to do some Bref specific stuff, so run these commands in your terminal:
cd laravel-statamic-serverless # Let's go into the Statamic app we've just created
composer require bref/bref bref/laravel-bridge
touch .dockerignore
touch Dockerfile
Your .dockerignore
file should look like this:
./cdk
Your Dockerfile
should look like this:
FROM bref/php-81-fpm
ADD . /var/task
CMD ["public/index.php"]
Let’s get this into Git, in your terminal:
git init
git add .
git commit -m 'initial commit'
I can’t find a way of telling Stache (statamics caching) to respect the Lambda storage path (/tmp/storage
), if you can figure it out, please let me know!
Change Stache locks to false here in the Statamic config file ./config/statamic/stache.php
...
/**
| https://statamic.dev/stache#locks
|
*/
'lock' => [
'enabled' => false,
'timeout' => 30,
],
build the styles and JS:
npm i
npm run prod
Because we’re running this in a Lambda function we’ll need to change the storage directory.
Inside your AppServiceProvider, let’s force it to use the writable /tmp
directory - let’s also set the APP_URL as the root url (otherwise Cloudfront and API Gateway start arguing about who’s who) - in ./app/Providers/AppServiceProvider.php
’s boot function:
public function boot()
{
app()->useStoragePath("/tmp/storage");
url()->forceRootUrl(env('APP_URL'));
if (! is_dir(config('view.compiled'))) {
mkdir(config('view.compiled'), 0755, true);
}
if (! is_dir("/tmp/storage/framework/cache")) {
mkdir("/tmp/storage/framework/cache", 0777, true);
}
}
Local test
Check your site works locally, if you’re running valet, try http://laravel-statamic-serverless.test/
You should get something like this:
CDK Part One
In your terminal, let’s init a new CDK instance:
mkdir cdk && cd cdk
cdk init app -l=python
Install a couple of dependencies that we’ll need down the line, update your ./cdk/requirements.txt
so it looks like this:
aws-cdk-lib==2.26.0
constructs>=10.0.0,<11.0.0
aws-cdk.aws-apigatewayv2-integrations-alpha
aws-cdk.aws-apigatewayv2-alpha
cdk-monitoring-constructs
Install these requirements (you’ll need to be in the ./cdk
directory in your terminal to do this):
source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
Open up the CDK stack that’s been generated for us, and let’s get the bare bones in place:
File: ./cdk/cdk/cdk_stack.py
from aws_cdk.aws_apigatewayv2_integrations_alpha import HttpLambdaIntegration
from cdk_monitoring_constructs import MonitoringFacade
from aws_cdk.aws_apigatewayv2_alpha import HttpApi
from aws_cdk import (
CfnOutput,
Duration,
Stack,
aws_lambda as _lambda,
aws_s3 as s3,
)
from constructs import Construct
class CdkStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# We create a Docker Image function to get around the 250mb limit
# This is where Laravel lives
laravel_web = _lambda.DockerImageFunction(self, "LaravelWeb",
code=_lambda.DockerImageCode.from_image_asset("../"),
memory_size=1024,
timeout=Duration.seconds(20),
)
# This is integrating our Lambda function with our HTTP Api
web_integration = HttpLambdaIntegration("LaravelWebIntegration", laravel_web)
# This creates our HttpApi and sets the integration we've just created above
endpoint = HttpApi(self, "ApiGateway",
default_integration=web_integration
)
# Let's add some monitoring around our resources!
monitoring = MonitoringFacade(self, "LaravelServerlessMonitoringFacade")
monitoring.add_large_header("Serverless Laravel Blog")
monitoring.monitor_api_gateway_v2_http_api(api=endpoint)
monitoring.monitor_lambda_function(lambda_function=laravel_web)
# This will output into the terminal after a successful deploy
CfnOutput(self, "ApiGatewayURL", value=endpoint.url)
Here we’re creating the Docker Image Lambda function and pointing to our Dockerfile
(the .dockerignore
file will stop CDK trying to include the cdk
directory, stopping it from getting stuck in a loop).
CDK will create the ECR for us and upload the image to it, which is awesome.
We then create the API Gateway HTTP API and attach it to the function, any calls to this API (to any endpoint) will be forwarded to it.
We then add a little bit of monitoring around the API and function, so we can have some insight into how things are running.
Let’s deploy this and see what we get:
cdk deploy
Say yes to the security prompt (it’s making a couple of roles and giving those roles least priviledge access - i.e. API Gateway can invoke your Lambda) - you’ll now have an API and Lambda function running. Once complete, the console should output your API’s endpoint. Let’s visit it:
It’s trying to load our assets via Laravel, but that’s static content.
Static Content
Let’s update our cdk_stack.py
file to look like this:
from aws_cdk.aws_apigatewayv2_integrations_alpha import HttpLambdaIntegration
from cdk_monitoring_constructs import MonitoringFacade
from aws_cdk.aws_apigatewayv2_alpha import HttpApi
from aws_cdk import (
CfnOutput,
Duration,
Stack,
Fn,
aws_lambda as _lambda,
aws_s3 as s3,
aws_s3_deployment as deployment,
aws_cloudfront as cloudfront,
aws_cloudfront_origins as origins
)
from constructs import Construct
class CdkStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# This is where all our static assets live.
bucket = s3.Bucket(self, "StorageBucket",
block_public_access=s3.BlockPublicAccess.BLOCK_ALL
)
laravel_web = _lambda.DockerImageFunction(self, "LaravelWeb",
code=_lambda.DockerImageCode.from_image_asset("../"),
memory_size=1024,
timeout=Duration.seconds(20),
)
# Later on, we can have Laravel us the S3 Filesystem, so this lets
# Laravel read and write objects in the bucket
bucket.grant_read_write(laravel_web)
web_integration = HttpLambdaIntegration("LaravelWebIntegration", laravel_web)
endpoint = HttpApi(self, "ApiGateway",
default_integration=web_integration
)
# This allows Cloudfront access to our assets bucket
oai = cloudfront.OriginAccessIdentity(self, "CloudfrontOAI")
endpoint_uri = Fn.select(2, Fn.split("/", endpoint.url))
# We need to stop the HOST header getting through to API Gateway
# This also passes all cookies and query strings through too
laravel_cache_policy = cloudfront.CachePolicy(self, "LaravelCachePolicy",
cookie_behavior=cloudfront.CacheCookieBehavior.all(),
header_behavior=cloudfront.CacheHeaderBehavior.allow_list(
'Accept',
'Accept-Language',
'Origin',
'Referer'),
query_string_behavior=cloudfront.CacheQueryStringBehavior.all()
)
# Ths primary behavior of our cloudfront distro is to route traffic to our
# API gateway. We're allowing ALL methods.
distro = cloudfront.Distribution(self, "CloudfrontDistro",
enable_logging=True,
default_behavior=cloudfront.BehaviorOptions(
origin=origins.HttpOrigin(endpoint_uri),
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cache_policy=laravel_cache_policy
),
price_class=cloudfront.PriceClass.PRICE_CLASS_100
)
# This additional behavior diverts all traffic with the path starting
# /assets to our S3 bucket
distro.add_behavior("/assets/*",
origin=origins.S3Origin(
origin_access_identity=oai,
bucket=bucket
),
allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
origin_request_policy=cloudfront.OriginRequestPolicy.CORS_S3_ORIGIN
)
# Let's pass some important environment variables to Laravel
laravel_web.add_environment("MIX_ASSET_URL", "/assets")
laravel_web.add_environment("ASSET_URL", "/assets")
laravel_web.add_environment("AWS_BUCKET", bucket.bucket_name)
laravel_web.add_environment("FILESYSTEM_DISK", "s3")
laravel_web.add_environment("APP_URL", f"https://{distro.domain_name}")
# This is a tasty feature of CDK - we can get the `cdk deploy` command
# to lift our assets from the public folder into our S3 bucket
# notice how I move both files from public and files from public/assets here
# otherwise the asset files would be in /assets/assets....
deployment.BucketDeployment(self, "StaticAssetsDeployment",
sources=[
deployment.Source.asset("../public"),
deployment.Source.asset("../public/assets")
],
destination_bucket=bucket,
destination_key_prefix="assets",
exclude=["*.php"]
)
monitoring = MonitoringFacade(self, "ServerlessLaravelMonitoringFacade")
monitoring.add_large_header("Serverless Laravel Blog")
monitoring.monitor_api_gateway_v2_http_api(api=endpoint)
monitoring.monitor_lambda_function(lambda_function=laravel_web)
# Add the new resources to the dashboard
monitoring.monitor_cloud_front_distribution(distribution=distro)
monitoring.monitor_s3_bucket(bucket=bucket)
CfnOutput(self, "DistributionURL", value=distro.domain_name)
We have:
- Added a cloudfront distribution which will direct all requests with the path
/assets
to an S3 bucket, the rest of the traffic goes directly to the API Gateway HTTP API (with a nice cache policy) - Created an S3 bucket for our assets and an OAI so Cloudfront can get objects from it
- An S3 Deployment construct puts all our
public/
content onto the S3 inside an/assets
object - Created environment variables in our Laravel function that adds
./assets
to the uri’s. - Changes the output from the API Gateway to the Cloudfront distro domain
Let’s run cdk deploy
again and see where this gets us…
Try a couple of things:
- Create a user locally and deploy with that new YAML file (
php please make:user
) - Visit the
/cp
control panel and have a play around there - note, you’ll only be able to edit stuff on your local - for now!
Monitoring
Now - the best bit - go and check out your CloudWatch dashboard
It’s a little busy, but it’s awesome to see it’s all in place for us from just a couple lines of python.
Tests
Let’s make sure each of our resources are at least in the template and resemble what we’re expecting.
Change your unit test file ./cdk/tests/unit/test_cdk_stack.py
look like this:
import aws_cdk as core
import aws_cdk.assertions as assertions
from cdk.cdk_stack import CdkStack
def test_core_resources_created():
app = core.App()
stack = CdkStack(app, "cdk")
template = assertions.Template.from_stack(stack)
template.has_resource_properties("AWS::Lambda::Function", {
"MemorySize": 1024,
"Timeout": 20,
"PackageType": "Image"
})
template.has_resource_properties("AWS::ApiGatewayV2::Api", {
"ProtocolType": "HTTP"
})
template.has_resource_properties("AWS::ApiGatewayV2::Route", {
"RouteKey": "$default",
"AuthorizationType": "NONE",
})
template.has_resource_properties("AWS::ApiGatewayV2::Integration", {
"IntegrationType": "AWS_PROXY"
})
template.has_resource_properties("AWS::ApiGatewayV2::Stage", {
"StageName": "$default",
"AutoDeploy": True
})
template.has_resource_properties("AWS::S3::Bucket", {
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": True,
"BlockPublicPolicy": True,
"IgnorePublicAcls": True,
"RestrictPublicBuckets": True
},
})
template.has_resource_properties("AWS::CloudFront::Distribution", {
"PriceClass": "PriceClass_100"
})
Run this in the terminal with pytest
- you should get all greens!
How are you editing the content?
I am editing the content locally in Markdown then running:
php please stache:clear
php please stache:warm
php please stache:refresh
in my terminal, then deploying those changes when I am happy with them.
Good luck!