JoyConf 2026 is back. Content Confidence. Human Connection. Save your spot!

Deploy a Storyblok static site to S3 and CloudFront with OpenTofu

Storyblok is the first headless CMS that works for developers & marketers alike.

Today we’re going to walk through deploying a Storyblok-powered Astro static site to AWS using a stack you fully own: a private S3 bucket as the origin, CloudFront as the CDN, OpenTofu for infrastructure-as-code, and GitHub Actions with OIDC so you never store a single AWS key.

The full pipeline we will build will look like this:

Storyblok (headless CMS) → Astro (static site generator) → S3 (private, encrypted artifact store) → CloudFront (global CDN) → end user

Hint:

The full implementation is at github.com/storyblok/s3-webinar-demo. Code examples in this article match the repository but are trimmed for readability. Refer to the repo for validation blocks, full IAM policies, and additional outputs.

The control spectrum

Now, you might be thinking: why not just use Vercel, Netlify, or AWS Amplify? Honestly, those are great choices, especially when getting live fast is the main goal.

When we talk about different deployment architectures, the choices generally fall on two ends of a spectrum.

The Control Spectrum: From managed platform to composed infrastructure
The Control Spectrum: From managed platform to composed infrastructure

On one side, you have options that specialize in speed and getting live fast by abstracting away all of the tools in a managed platform that enables your app to be deployed and hosted. These are options like Amplify, Vercel, or Netlify.

On the other end are options where your team needs more control over the exact tools you are using. A lot of times they’re built with the same primitives (e.g S3, a CDN, serverless functions) that these other services are built on, but in this case you own the exact configuration of how those pieces are working together and the knobs that configure them.

This is great for when your team needs:

  • Geographic and compliance control: you decide exactly where content is served and cached. AWS also has many certifications that are needed for regulated industries.
  • Audit-grade security: no stored cloud credentials, instead every permission you use is codified and reviewable.
  • IaC standardization: every environment (preview, staging, production) reproducable and deployed in the same manner
  • A decoupled build/deploy model: CI artifacts promoted independently across environments, and your architecture deployed and updated from these same environments
  • AWS consolidation: your organization is already on AWS, so hosting rolls into existing billing, discounts, and vendor approvals with nothing new to procure
  • Predictable costs at scale: linear pay-per-use pricing with no per-seat fees and no bandwidth overage cliffs when traffic spikes
  • Full control of the delivery pipeline: security headers, redirects, error pages, and cache policies configured directly at the CDN instead of through a platform’s config abstraction, so you’re never waiting on a feature the platform hasn’t exposed

If none of that applies to you, a managed platform is probably the better move. And that’s fine! Infrastructure ownership is a tool, not a badge.

Prerequisites

Before we dive in, make sure you have:

  • A Storyblok Space with at least one published story, and a Preview Token
  • Node.js 20+
  • An AWS account with admin-level access (for initial setup only — the OIDC roles we create later replace this with least-privilege access for day-to-day operations)
  • OpenTofu 1.8+
  • A GitHub repository for the project

Part 1: The static site layer with Astro + Storyblok

Let’s start with the application itself. We’re using Astro as a pure static site generator (output: 'static'), which means the build spits out a dist/ folder of plain HTML, CSS, JS, and other assets. There is no server runtime at all. This matters because it means the output is a completely portable artifact. It doesn’t care where it’s hosted. The CMS and hosting strategy are fully decoupled, which is exactly what we want. The great news is that the Storyblok CLI can get us up and running with a space and project in no time:

Scaffold the project

npx storyblok create storyblok-astro-s3 --template astro
cd storyblok-astro-s3
npm install

Astro config

One quick thing to confirm: make sure Astro is set to static output. This is actually the default, but being explicit makes it clear in case there are questions down the road:

astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  output: "static",
});

Run the build:

npm run build

You should see a dist/ folder containing one index.html per route in your Storyblok space, plus your static assets. This folder is your artifact meaning it’s the only thing the hosting infrastructure needs to know about. Everything from here on is about getting this folder to users as fast and securely as possible.

Part 2: The hosting infrastructure, private S3 + CloudFront via OpenTofu

Now for the fun part. Let’s define the AWS resources that will serve our artifacts globally. The architecture has three components:

  1. A private S3 bucket that stores the build artifacts
  2. A CloudFront distribution that reads from that bucket and serves content at the edge
  3. A CloudFront Function that rewrites clean URLs to index.html paths

Everything lives in an infra/ directory at the project root and is defined using OpenTofu.

Why a private bucket?

Your first instinct might be to enable S3 Static Website Hosting and make the bucket public. That works, but it creates a risk surface: the bucket is directly addressable on the internet, and any misconfiguration in the bucket policy exposes your content (or worse, allows writes). By keeping the bucket completely private and granting read access only to CloudFront via Origin Access Control (OAC), you eliminate that entire class of misconfiguration. The only way anyone reaches your content is through the CDN, which is great because then it ensures that your users will always get the fastest delivery of that content.

OpenTofu setup

This article assumes a working knowledge of OpenTofu (or Terraform). We won’t cover every implementation detail, but the code examples include enough context for you to follow the architecture and understand how the pieces connect.

New to OpenTofu?:

OpenTofu is an open-source infrastructure-as-code tool and a fork of Terraform. The HCL syntax and provider ecosystem are fully compatible, so if you’re already using Terraform, the concepts and code in this article transfer directly. You write declarative .tf files describing the resources you want, and OpenTofu creates, updates, or destroys them to match. Install it from the official releases page and verify with tofu version.

Project layout

We’ll keep infrastructure isolated so application changes do not accidentally trigger infra changes:

  • infra/: OpenTofu files (.tf)
  • .github/workflows/: separate deploy.yml and infra.yml

Core workflow

Every infrastructure change follows the same loop: init → plan → apply. This keeps changes predictable and reviewable, so you can see what will happen before AWS is modified.

cd infra

# Download provider plugins and initialize the backend
tofu init

# Preview what will change (always review before applying)
tofu plan -out=tfplan

# Apply the exact plan you just reviewed
tofu apply tfplan

#### Other helpful commands ####

# Auto-format files for consistency
tofu fmt -recursive

# Catch syntax errors early
tofu validate

# When you're done experimenting, tear everything down
tofu destroy 

How OpenTofu (Terraform) works

If you have not used Terraform or OpenTofu much, it helps to understand what is happening under the hood.

OpenTofu reads your .tf files and builds a dependency graph of resources. It then compares the desired state in code to the saved state in terraform.tfstate and computes a plan.

  • tofu init downloads providers and configures the backend where state will live.
  • tofu plan shows the exact changes OpenTofu would make.
  • tofu apply executes that plan by calling AWS APIs through the provider.

Before any of this can work, your configuration needs to answer three questions for OpenTofu:

  1. Which APIs am I calling? Provider configuration: the cloud, the region, and how OpenTofu authenticates.
  2. What makes this deployment different from any other? Input variables: the knobs that keep resource names and settings consistent across environments.
  3. What already exists, and what needs to change? The state backend: where state lives and who can read or write it.

The easiest way to understand the configuration is to follow the lifecycle from a blank folder to a deployed CDN.

First, tell OpenTofu what it can talk to

Provider configuration answers the first question OpenTofu asks: which APIs am I calling? Declaring the AWS provider in providers.tf teaches OpenTofu the vocabulary for resources like aws_s3_bucket and aws_cloudfront_distribution. Configuring it mostly means picking a default region. CloudFront is global and S3 feels global, but the AWS API still routes many operations through a regional endpoint, so choosing a region early avoids confusing drift later.

infra/providers.tf
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.default_tags
  }
}

Notice what this file does not include: credentials. OpenTofu has no idea how to log in. The AWS provider reads credentials from the same places the AWS CLI does. Locally that means profiles, environment variables, or SSO. In CI it means an IAM role assumed through OIDC. Keeping authentication out of the .tf files entirely is what lets the exact same code run on your laptop and in GitHub Actions without changing a line. (Hold onto that default_tags block, it pays off in the next section.)

Next, define the inputs that shape each environment

Variables answer the second question: what makes this deployment different from any other? A short project_name prefix flows through every resource name, an environment variable separates dev from staging from production, and a tags map labels every resource for cost allocation and inventory.

infra/variables.tf
variable "project_name" {
  type        = string
  description = "Short project identifier used for resource names and tags."
}

variable "environment" {
  type        = string
  description = "Environment name (dev, staging, prod)."
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Extra tags to apply to all resources."
}

locals {
  name_prefix = "${var.project_name}-${var.environment}"

  default_tags = merge({
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "opentofu"
  }, var.tags)
}

So with project_name = "acme" and environment = "prod", a bucket defined as:

resource "aws_s3_bucket" "site" {
  bucket = "${local.name_prefix}-site"
}

comes out named acme-prod-site, and the default_tags block from the previous section stamps it with Project = acme and Environment = prod automatically. No tags argument on the resource at all. Spin up staging by changing one variable and you get acme-staging-site with matching tags. None of this changes what gets built. It changes how you find it, bill it, and reproduce it.

Then, decide where state lives

State answers the third question: what already exists, and what needs to change? If you are one person running OpenTofu locally, local state works for a while. The moment CI or a second human enters the picture, you want a remote backend, and the standard AWS pattern is an S3 bucket for the state file plus a DynamoDB table that locks it so two applies can’t race each other.

State becomes the contract between our two workflows. The infra workflow writes state when it creates resources. The deploy workflow reads outputs from it to discover the bucket name and distribution ID it needs. We’ll set up the backend itself, and the access boundaries around it, later in this article.

That’s the whole mental model: a provider so OpenTofu can talk to AWS, variables to shape each environment, and state to remember what was built. Now let’s write it.

Gotcha: do not store credentials in state. :

If you ever add values like API tokens, mark them sensitive = true and prefer passing them via environment variables. Treat the state bucket as highly sensitive data.

Now that the strategy is clear, let’s run through it together.

Providers and variables

First, we configure the providers we need and any variables.

infra/backend.tf
terraform {
  required_version = ">= 1.8.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket         = "terraform-state"
    key            = "s3-webinar-demo/infra.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

# infra/providers.tf
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.default_tags
  }
}
infra/variables.tf
variable "github_repository" {
  type        = string
  description = "GitHub org/repo for OIDC trust policy (e.g. my-org/s3-webinar-demo)."
}

variable "project_name" {
  type        = string
  description = "Short project identifier used for resource names and tags."
  default     = "s3-webinar-demo"
}

variable "environment" {
  type        = string
  description = "Deployment environment name used for tagging."
  default     = "production"
}

variable "aws_region" {
  type        = string
  description = "AWS region for all resources."
  default     = "eu-west-1"
}

variable "s3_force_destroy" {
  type        = bool
  description = "Allow OpenTofu to delete the site bucket even if it contains objects."
  default     = false
}

variable "cloudfront_price_class" {
  type        = string
  description = "CloudFront price class for distribution."
  default     = "PriceClass_100"
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Extra tags to apply to all resources."
}

# infra/locals.tf
locals {
  name_prefix  = "${var.project_name}-${var.environment}"
  site_bucket  = "${local.name_prefix}-site"
  s3_origin_id = "${local.name_prefix}-s3-origin"
  
  default_tags = merge({
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "opentofu"
  }, var.tags)
}

A few variables and locals are worth calling out before we move on.

cloudfront_price_class controls which edge locations serve your traffic, and each tier has a different cost profile. PriceClass_100 restricts distribution to North America and Europe (the cheapest tier). PriceClass_200 adds Asia, Africa, and the Middle East. PriceClass_All gives you everywhere, including South America and Australia. If your users are all in Europe, there is no reason to pay for edge locations in São Paulo. Making it a variable means each environment can tune this independently. For example, perhaps for dev you just need your content in North America or Europe and for production you want worldwide distribution.

locals block defines a consistent naming convention (project_name-environment-site) and the default_tags map that flows into every AWS resource via the provider’s default_tags block. The s3_origin_id local is reused in the CloudFront distribution. This pattern keeps resource names predictable and unique across environments.

s3_force_destroy defaults to false as a safety net. S et it to true only in dev environments where you want OpenTofu to tear down the bucket even with objects in it.

Variables vs. locals:

A variable is an input, set from outside the configuration through -var flags, a .tfvars file, or TF_VAR_* environment variables. A local is a value computed inside the configuration, typically derived from variables, and can’t be overridden from outside. If someone decides the value per environment it should be a variable, and if the value follows from those decisions (like name_prefix) it should be a local.

The S3 bucket

Now that we have our backend, variables, and providers configured let’s setup the bucket that we will upload all of our build assets to.

infra/main.tf
resource "aws_s3_bucket" "site" {
  bucket        = local.site_bucket
  force_destroy = var.s3_force_destroy
}

resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
  bucket = aws_s3_bucket.site.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "site" {
  bucket                  = aws_s3_bucket.site.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

A few things to note:

  • All four public access blocks are set to true: Even if someone accidentally attaches a public bucket policy, these blocks prevent it from doing anything.
  • Server-side encryption: AES256 (SSE-S3) encrypts objects at rest at zero additional cost. Swap the algorithm for SSE-KMS if compliance requires customer-managed keys.

Notice what’s not here: there’s no aws_s3_bucket_website_configuration resource. We’re deliberately skipping S3’s built-in static website hosting. That means S3 won’t resolve /about to /about/index.html on its own, but it also means the bucket has no public HTTP endpoint at all. We’ll solve the URL rewriting problem at the CDN layer instead.

The CloudFront Function, solving clean URLs

This is where most people hit their first “wait, why is everything 403?” moment with private S3 origins. When S3 Website Hosting is disabled, S3 behaves like a plain key-value store. A request for /about looks for an object literally named about in the bucket. But our build process generates /about/index.html, so S3 can’t find it and returns a 403 (access-denied rather than 404, because the bucket is private).

The fix is a CloudFront Function that intercepts every viewer request and rewrites the URI before it hits the origin:

infra/main.tf
resource "aws_cloudfront_function" "rewrite_uri" {
  name    = "${local.name_prefix}-rewrite-uri"
  runtime = "cloudfront-js-2.0"
  publish = true
  code    = <<-EOF
    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;
    }
  EOF
}

Why a CloudFront Function and not Lambda@Edge? CloudFront Functions run at every edge location (not just regional edge caches), execute in under 1ms, and cost roughly 1/6th the price of Lambda@Edge invocations. The tradeoff is a more constrained runtime. You get no network calls, no filesystem access, a 10KB code size limit, and the cloudfront-js-2.0 runtime (a subset of ECMAScript). For URL rewriting, though, that’s more than enough.

Gotcha, the dot check:

The rewrite logic uses uri.includes(".") to decide whether a path is a file or a route. This works well for typical static sites, but will break if you have routes that contain dots (e.g., /api/v2.0/docs). If your site has paths like that, use a more specific check like testing for known extensions (/\.(html|css|js|png|jpg|svg|ico|woff2?)$/).

The CloudFront distribution

This is the biggest single resource in our stack. Let’s walk through the key decisions piece by piece.

infra/main.tf
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "${local.name_prefix}-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = true
  is_ipv6_enabled     = true
  comment             = "Managed by OpenTofu"
  default_root_object = "index.html"
  price_class         = var.cloudfront_price_class

  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
    origin_id                = local.s3_origin_id
  }

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.s3_origin_id
    viewer_protocol_policy = "redirect-to-https"
    compress               = true
    cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.rewrite_uri.arn
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

A few things to note here:

Origin Access Control (OAC) is the modern replacement for the older Origin Access Identity (OAI). OAI still works, but AWS recommends OAC for new distributions.

bucket_regional_domain_name: Use this instead of bucket_domain_name. The regional variant avoids some annoying temporary redirect issues that can pop up during the first few hours after bucket creation in certain regions. Trust me on this one.

Managed-CachingOptimized: This is an AWS-managed cache policy that sets a default TTL of 86400 seconds (24 hours), respects Cache-Control and Expires headers from the origin, and enables Gzip and Brotli compression.

Geo-restriction is set to none: This is our starting point. If you need to block or allow specific countries for regulatory or contractual reasons, change restriction_type to "whitelist" or "blacklist" and provide a list of ISO 3166-1 alpha-2 country codes. This is exactly the kind of decision that justifies owning the distribution.

The bucket policy, scoped to exactly one distribution

Our private bucket needs a policy that essentially says: “CloudFront can read objects, but only when the request comes from this specific distribution.” We can control this with aws:SourceArn.

infra/main.tf
data "aws_iam_policy_document" "site_bucket_policy" {
  statement {
    sid     = "AllowCloudFrontRead"
    effect  = "Allow"
    actions = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [aws_cloudfront_distribution.site.arn]
    }
  }
}

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id
  policy = data.aws_iam_policy_document.site_bucket_policy.json

  depends_on = [aws_s3_bucket_public_access_block.site]
}

Without that SourceArn condition, any CloudFront distribution in any AWS account could be configured to read from your bucket (as long as it uses the cloudfront.amazonaws.com service principal). The condition locks it down to your single distribution ARN.

The depends_on on the policy resource is also worth noting. It ensures the public access block is fully applied before the bucket policy attaches. Without that ordering, a race condition could briefly leave the bucket with a policy but no public access protection.

Outputs

Our CI/CD pipeline is going to need to know the bucket name and distribution ID. Rather than hardcoding these (please don’t), we export them as OpenTofu outputs. The deploy workflows in GitHub Actions will then be able to read them dynamically and use them, and if you need to make any changes in the future it will automatically pick them up the next deploy.

infra/outputs.tf
output "site_bucket_name" {
  description = "S3 bucket name for the static site."
  value       = aws_s3_bucket.site.id
}

output "cloudfront_distribution_id" {
  description = "CloudFront distribution ID."
  value       = aws_cloudfront_distribution.site.id
}

output "cloudfront_domain_name" {
  description = "CloudFront distribution domain name."
  value       = aws_cloudfront_distribution.site.domain_name
}

output "deploy_role_arn" {
  description = "ARN of the GitHub Actions deploy role (OIDC, least-privilege)."
  value       = aws_iam_role.github_deploy.arn
}

output "infra_role_arn" {
  description = "ARN of the GitHub Actions infra role (OIDC, Terraform apply)."
  value       = aws_iam_role.github_infra.arn
}

Apply the infrastructure

Now it’s time for everything to come together. Let’s deploy the infrastructure to our account with the CLI.

cd infra
tofu init
tofu plan -out=tfplan
tofu apply tfplan

Before running plan, make sure all the variables we defined earlier are set. Variables with defaults (project_name, environment, aws_region, s3_force_destroy, cloudfront_price_class) will use their default values if you don’t override them. Variables without defaults (github_repository) must be provided if they’re required. You can pass them directly with -var flags, through a terraform.tfvars file, or as environment variables prefixed with TF_VAR_:

# Option 1: -var flags
tofu plan -var "github_repository=my-org/s3-webinar-demo" -out=tfplan

# Option 2: terraform.tfvars (auto-loaded)
github_repository = "my-org/s3-webinar-demo"
project_name      = "storyblok-astro"

# Option 3: environment variables
export TF_VAR_github_repository="my-org/s3-webinar-demo"
tofu plan -out=tfplan

Once apply completes, grab the cloudfront_domain_name output. You can test the distribution by visiting https://<cloudfront_domain_name> in a browser — it’ll return a 403 for now because the bucket is empty, but that’s expected. We’ll fix that next.

Gotcha, distribution propagation:

A new CloudFront distribution takes 5–15 minutes to propagate globally. If you deploy content immediately and get 403 errors, wait for the distribution status to change from “InProgress” to “Deployed” in the AWS console.

Part 3: Deploying content with S3 sync and cache invalidation

With the infrastructure in place, deploying is straightforward: upload the build artifact to S3, then tell CloudFront to drop its cached copies.

Manual deploy (for testing)

npm run build

aws s3 sync ./dist s3://YOUR_BUCKET_NAME \
  --delete \
  --cache-control "public, max-age=300"

aws cloudfront create-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --paths "/*"

Let’s unpack what each flag is doing:

  • --delete removes objects from the bucket that no longer exist in dist/. Without it, stale pages or assets from previous builds persist and are still served.
  • --cache-control "public, max-age=300" tells CloudFront (and browsers) to cache every object for 5 minutes. This is a conservative starting point for HTML. In a production setup, you would apply different headers per file type: max-age=31536000, immutable for hashed assets (JS/CSS bundles with content hashes in the filename), and a shorter TTL for HTML. More on this later.
  • --paths "/*" is a wildcard invalidation. It is the simplest approach but comes with a cost tradeoff: AWS gives you 1,000 free invalidation paths per month, and a wildcard counts as one path. Beyond that, each additional path costs $0.005. For most sites with regular deploys, the free tier is sufficient.
Gotcha: s3 sync is not atomic:

During the upload window, the bucket is in a mixed state: some objects are from the new build, some from the old. For a static site with no cross-page dependencies, this is usually fine. If you need atomic deploys (e.g., your JS bundles reference specific CSS hashes), consider deploying to a versioned prefix and swapping the CloudFront origin path, or using S3 object versioning.

Now after we deploy and go to the Cloudfront distribution url we can see our page!

At this point, we have a complete end-to-end pipeline working: we built a portable dist/ artifact, provisioned a private S3 origin and a CloudFront distribution with clean-URL rewriting, then deployed content with s3 sync and confirmed it is being served through the CDN instead of a public bucket endpoint. The diagram below visualizes the flow a user will experience on the hosting and delivery side of that setup (build artifact → S3 → CloudFront → end user).

Hosting Architecture: Private S3 origin behind CloudFront, deployed from a local machine.l
Hosting Architecture: Private S3 origin behind CloudFront, deployed from a local machine.l

This process is working great, but it will be a pain to have to build and deploy from your local machine every time there are changes you want pushed out to production. Now let’s take what we’ve built and automate the deployment with GitHub Actions so we don’t need to always deploy from our local machines. We’ll cover this in our next post: Automate Storyblok deploys to S3 and CloudFront with GitHub Actions.

Author

A smiling man with glasses and a patterned shirt stands in front of a window with a blurred green outdoor background.

Brenden Niedermeyer

Brenden is a Principal Solutions Architect at Storyblok. He works with enterprise teams on architecture reviews and integrations, and he helps them deliver content-driven digital experiences that hold up that scale.

Over nearly 15 years of building software, he has designed high-performance web applications end to end, from full-stack architecture through delivery pipelines and cloud infrastructure. He enjoys the work of connecting technical decisions to business goals, and he solves problems hands-on, in code and in working sessions with customer teams.