# landonmiles.com - Full Content Export > Complete content export of the About page and every landonmiles.com blog post. > Intended for AI/LLM indexing and retrieval. > Latest publication: 2026-01-25 > Last content update: 2026-07-28 ## Site Information - Author: Landon Miles - Role: Senior Manager, Technical Marketing & Applied AI at Automox - Location: Oklahoma City, OK, USA - Website: https://landonmiles.com - Email: hello@landonmiles.com - LinkedIn: https://www.linkedin.com/in/landonmiles - GitHub: https://github.com/jlmiles4 - Project: PDF Comments (https://pdfcomments.app), a browser-based PDF annotation extractor - Project: react-pdf-gen (https://github.com/jlmiles4/react-pdf-gen), an open-source React and TypeScript PDF-generation guide and reference project - Project: Discover Automox (https://discover.automox.com/demos), an interactive product-experience platform designed and built by Landon ## Summary Landon Miles is a technical marketer and developer. He writes for IT operations teams at Automox and publishes on technical marketing, AWS, AI tools, and command-line workflows. He also builds small side-project tools for problems that existing options do not solve well. --- ## About Page ### PHILOSOPHY I like problems more than I like any particular technology. Debugging a weird bug, figuring out how to explain something hard, picking the right tool for a job – that's the fun part. I'm a generalist by temperament. Python, JavaScript, MongoDB, PostgreSQL, React, Next.js – whatever fits. I don't have a favorite stack and I distrust people who do. The right answer to a problem is usually smaller than you think. A lot of what I build is a script, a Lambda, or a static page someone can load without thinking about it. ### WORK & PROJECTS **Technical Marketing:** Senior Manager, Technical Marketing & Applied AI at [Automox](https://www.automox.com). Most of my writing there is aimed at IT operations teams – the people who have to run the software. **Podcasting:** I host and produce the [Hands-On IT podcast](https://www.youtube.com/playlist?list=PLbBlhOg7rvKCTycMQraG0umaWB6GsqdKu). Practical topics and expert interviews for IT operations teams. **Development:** A mix of things. Custom DAM systems, workflow scripts, the occasional side-project site when I have an itch to scratch. Outside of work I'm usually with my wife, dog, and kiddos. --- ## Content by Category ### Development - [PDFs Are Feedback Traps](https://landonmiles.com/blog/pdfcomments-app) - [Terminal Velocity: Accelerate Your Workflow with the Command Line](https://landonmiles.com/blog/terminal-velocity) - [Building Static Sites with AWS S3 and CloudFront](https://landonmiles.com/blog/static-sites-and-aws) ### Technical Marketing - [Avoiding Hype: How to Write Honestly About Complex Products](https://landonmiles.com/blog/avoiding-hype) - [The Key to Technical Marketing: Find Your LEGO Level](https://landonmiles.com/blog/find-your-lego-level) --- ## Content by Tag ### aws - [Building Static Sites with AWS S3 and CloudFront](https://landonmiles.com/blog/static-sites-and-aws) ### cli - [Terminal Velocity: Accelerate Your Workflow with the Command Line](https://landonmiles.com/blog/terminal-velocity) ### communication - [Avoiding Hype: How to Write Honestly About Complex Products](https://landonmiles.com/blog/avoiding-hype) ### content-strategy - [The Key to Technical Marketing: Find Your LEGO Level](https://landonmiles.com/blog/find-your-lego-level) ### deployment - [Building Static Sites with AWS S3 and CloudFront](https://landonmiles.com/blog/static-sites-and-aws) ### pdf - [PDFs Are Feedback Traps](https://landonmiles.com/blog/pdfcomments-app) ### product-marketing - [Avoiding Hype: How to Write Honestly About Complex Products](https://landonmiles.com/blog/avoiding-hype) - [The Key to Technical Marketing: Find Your LEGO Level](https://landonmiles.com/blog/find-your-lego-level) ### productivity - [Terminal Velocity: Accelerate Your Workflow with the Command Line](https://landonmiles.com/blog/terminal-velocity) ### side-project - [PDFs Are Feedback Traps](https://landonmiles.com/blog/pdfcomments-app) ### static-sites - [Building Static Sites with AWS S3 and CloudFront](https://landonmiles.com/blog/static-sites-and-aws) ### technical-marketing - [Avoiding Hype: How to Write Honestly About Complex Products](https://landonmiles.com/blog/avoiding-hype) - [The Key to Technical Marketing: Find Your LEGO Level](https://landonmiles.com/blog/find-your-lego-level) ### terminal - [Terminal Velocity: Accelerate Your Workflow with the Command Line](https://landonmiles.com/blog/terminal-velocity) ### tools - [PDFs Are Feedback Traps](https://landonmiles.com/blog/pdfcomments-app) ### workflow - [PDFs Are Feedback Traps](https://landonmiles.com/blog/pdfcomments-app) - [Terminal Velocity: Accelerate Your Workflow with the Command Line](https://landonmiles.com/blog/terminal-velocity) --- ## All Posts ## PDFs Are Feedback Traps - URL: https://landonmiles.com/blog/pdfcomments-app - Published: 2026-01-25 - Updated: 2026-07-28 - Category: Development - Tags: tools, pdf, workflow, side-project ### The extraction problem You know the drill. You generate a PDF – an architectural diagram, a draft blog post, a technical spec – and you send it to stakeholders for review. They do what you asked and mark it up with comments. Generating the PDF is easy. Commenting on it is easy. Getting the feedback back out into something you can work from is the part that kills an afternoon. If you have 40+ comment bubbles, the workflow looks like: 1. Open the PDF on monitor one. 2. Open your ticket tracker or Markdown file on monitor two. 3. Click comment, copy, alt-tab, paste, alt-tab, back to the PDF. Repeat until the queue is empty. It's manual, it's error-prone, and you miss things. The feedback ends up trapped in the document's annotation layer, separate from anywhere you'd triage work. What I wanted wasn't a better PDF viewer. I wanted something that would take a marked-up PDF and give me back a list of tasks. --- ### Why existing exports didn't fit my workflow Before building, I looked at the existing options. None produced the page-tagged Markdown checklist I wanted. * **Adobe Acrobat:** Acrobat can create comment summaries, [export FDF or XFDF data, and send comments through certain Word workflows](https://helpx.adobe.com/acrobat/using/importing-exporting-comments.html). Those formats preserve review data, but they didn't give me the clean Markdown task list I wanted. * **Online converters:** Many PDF-to-text tools ignore annotations. Using a hosted converter would also mean uploading documents I preferred to keep local. * **Python scripts:** Libraries such as `pypdf` make a custom extractor possible, but that still requires a development environment and document-specific parsing logic. The gap was clear: drag, drop, copy, done – a utility that runs entirely in the browser. --- ### How it's built **Stack:** Next.js 16 (static export) :: React 19 :: TypeScript :: Tailwind CSS 4 :: PDF.js 5. #### It all runs in the browser PDFs often contain sensitive data – contracts, internal memos, unreleased specs. Building this as a client-side app on top of `pdfjs-dist` means the app does not upload the selected PDF to an application server. Extraction runs locally in the browser, and once the app has loaded it can run without a network connection. That architecture removes the upload and processing round trip. It does not prove that every PDF will be fast; that would need a benchmark across document sizes and structures. #### Reconstructing context from geometry This was the interesting part to build. A PDF highlight annotation usually doesn't store the selected words. It stores page coordinates – "user drew a yellow rectangle at `[x, y]`" – that the tool has to map back to the PDF's text. To get your data back, the tool has to perform a geometric intersection: 1. **Extract Geometry:** Get the quad points (corners) of every highlight. 2. **Map the Text:** Parse the page to get the bounding box of every text item. 3. **Intersect:** Run a collision detection loop. If a text item overlaps with the highlight, it belongs to that comment. 4. **Sort:** PDF text isn't always stored in reading order. The tool sorts items by Y then X coordinates to reconstruct the sentence naturally. The upshot is that what looked like a pile of coordinates on disk comes out the other side as readable sentences tied to the right page. --- ### Getting the output somewhere useful Two export paths: | Action | Use Case | | :--- | :--- | | **Copy Checklist** | Paste into Google Docs (formatted list) or GitHub/Notion (GFM checkboxes). | | **Copy / Download Markdown** | Deep work. Includes page numbers and full context for AI agents or your favorite markdown editor. | For the review files that prompted the tool, the new loop takes seconds: receive the PDF, drop it into **pdfcomments.app**, and paste the checklist into your Google Doc. On my roughly 40-comment review files, I estimate that it removes about 15 minutes of mindless copying. That is a personal workflow estimate, not a benchmark across PDF types. More importantly, every annotation the parser extracts becomes a tickable box. Extraction still depends on how the PDF stores text and annotations. Image-only scans and unusual document structures may need manual cleanup. --- ### Try it **[pdfcomments.app](https://pdfcomments.app)** I built this on a snowy Saturday in about the runtime of the new Tron movie. It works for my use case – your mileage may vary. It's free, and the PDF content is processed locally in the browser. [View on GitHub](https://github.com/jlmiles4/pdfcomments.app). --- ## Terminal Velocity: Accelerate Your Workflow with the Command Line - URL: https://landonmiles.com/blog/terminal-velocity - Published: 2025-12-05 - Updated: 2026-07-28 - Category: Development - Tags: terminal, cli, workflow, productivity ### Why bother with the terminal You can build a long career without ever opening a terminal. Plenty of people do. If your work involves Linux systems, containers, cloud infrastructure, or automation, though, a shell becomes hard to avoid. That's the argument for learning it before you need it during an outage. The terminal isn't just a faster way to do what you already do with a mouse. It's one of the main interfaces for servers, containers, CI runners, cloud tooling, and Unix utilities. Most of what follows is the small amount of context and the small set of commands that I think are worth learning first, in roughly the order I'd introduce them to someone. --- ### A quick word on terminology People use "terminal" to mean three different things, which makes the whole topic more confusing than it needs to be. Originally, a terminal was a piece of hardware. A screen and a keyboard wired into a mainframe somewhere else in the building, with no processing power of its own. When personal computers took over, we kept the text-based interface and moved it into software. Today the pieces work like this: - **Terminal emulator.** The app you open (iTerm2, Alacritty, Windows Terminal, Ghostty, the default Terminal on macOS). It draws the window and handles input. - **Shell.** The program running inside that window (Bash, Zsh, Fish) that reads what you type, interprets it, and talks to the operating system. You can mix and match. Your prompt customizations, aliases, and most of the "look and feel" you associate with the terminal live in the shell, not the emulator. This distinction matters the first time you want to move your setup to a new machine. --- ### What it handles well A few things the shell tends to handle well: #### The skill travels VS Code's menus move around between versions. Windows Settings looks nothing like macOS System Settings. Meanwhile `ls`, `cd`, `grep`, and `ssh` have remained stable across POSIX-style environments for decades. Time spent learning them transfers among Linux, macOS, WSL, containers, and remote hosts. #### Pipes change what's possible The pipe operator (`|`) takes the output of one command and hands it to the next. That one idea is what turns a small set of commands into a general-purpose toolkit. For example, finding repeated error messages across a directory of logs: ```bash grep -h "ERROR" ./*.log | sort | uniq -c | sort -nr ``` In a GUI that's a manual search-and-count exercise. Here it's one line, and you can stick it in a script and forget about it. #### Remote work often starts with a shell If you end up anywhere near DevOps, backend, or cloud infrastructure, you'll spend real time in a shell. Managed services expose logs and dashboards, but containers, CI jobs, and remote Linux hosts are still commonly inspected and controlled with text commands. The sooner you're comfortable there, the less painful the first outage is. #### You learn how the computer works Using the terminal tends to push you into the model the OS is already using underneath. You stop guessing why a file won't save and start thinking about permissions (`chmod`, `chown`). You stop force-quitting from a menu and start thinking about processes and signals (`ps`, `kill`). You stop checking Wi-Fi bars and start reaching for `ping`, `curl`, and `dig`. None of this is strictly necessary to get work done – until it is. --- ### How to get comfortable The hardest part of learning the terminal is the first couple of weeks, when it's slower than what you already know. Some things that helped me: #### Replace one workflow at a time Pick something you do with a mouse and commit to doing it in the terminal for a week. - **Git.** Close the Source Control tab. `git status`, `git add -p`, `git commit`, `git log --oneline` will cover most of what you need. - **File navigation.** Skip Finder/Explorer for anything inside a project. `cd`, `ls`, `tree`. - **Reading logs.** Stop opening log files in a text editor. `tail -f`, `less`, `grep`. Full replacement isn't the goal. Just build the reflex to try the terminal first for one kind of task. #### Don't try to memorize man pages You don't need to learn every flag to every command. You need the daily drivers and the confidence to look up the rest when you need them. The core set is small. #### Make the environment yours The default terminal on most systems is ugly and unhelpful. A few changes make a real difference: - A modern shell (Zsh with something like Oh My Zsh, or Fish). - A prompt that tells you the current directory, git branch, and whether the last command failed. - A readable monospace font (I like Geist Mono and JetBrains Mono). - Two or three aliases for things you type all day – mine include `gs` for `git status` and `..` for `cd ..`. You don't need a 400-line dotfiles repo. Start small and add things as they become obviously useful. --- ### About platforms Linux is common across cloud hosts, containers, and small devices. SSH into an EC2 instance, a DigitalOcean droplet, or a Pi and you will usually land in a shell. The commands below target a POSIX-style environment and work on Linux and macOS. On Windows, WSL or Git Bash lets you follow them directly. PowerShell uses different commands and syntax, but it is a capable cross-platform shell and automation language in its own right. Pick the environment that matches the systems and documentation you work with. --- ### The cheat sheet The commands that come up again and again, roughly grouped by what they do. #### Navigation | Command | Description | Example | | :------ | :---------- | :------ | | `pwd` | Print working directory | `pwd` | | `ls` | List directory contents | `ls -l`, `ls -a` | | `cd` | Change directory | `cd ~`, `cd ..` | #### Files and directories | Command | Description | Example | | :------ | :---------- | :------ | | `mkdir` | Make a directory | `mkdir new_project` | | `touch` | Create an empty file / update its timestamp | `touch new_file.txt` | | `cp` | Copy files or directories | `cp file.txt backup/` | | `mv` | Move or rename files | `mv old.txt new.txt` | | `rm` | Remove files | `rm file.txt` | | `cat` | Dump a file to stdout | `cat log.txt` | | `less` | Read a file interactively | `less large_log.txt` | | `head` | First N lines | `head -n 10 file.txt` | | `tail` | Last N lines, optionally following | `tail -f log.txt` | Unlike moving a file to a desktop trash folder, `rm` usually has no built-in undo. Check the path before pressing Enter, and do not add recursive or force flags until you understand exactly what they will remove. #### Text and search | Command | Description | Example | | :------ | :---------- | :------ | | `grep` | Search for patterns in files | `grep "ERROR" app.log` | | `find` | Search for files | `find . -name "*.js"` | | `sort` | Sort lines | `sort names.txt` | | `uniq` | Collapse adjacent duplicate lines | `sort list.txt \| uniq` | #### System and network | Command | Description | Example | | :------ | :---------- | :------ | | `ps` | Snapshot of running processes | `ps aux` | | `top` | Live process monitor | `top` | | `htop` | Interactive process viewer | `htop` | | `kill` | Send a signal to a process | `kill 12345` | | `ping` | Basic connectivity check | `ping google.com` | | `curl` | Make HTTP requests / download files | `curl -O https://site.com/file.zip` | | `ssh` | Secure shell to a remote host | `ssh user@server.com` | --- Most of the tools in that table are old, stable, and likely to remain useful. Start with the five or six commands you'd use today. The rest sticks as soon as you have a reason to reach for it. --- ## Avoiding Hype: How to Write Honestly About Complex Products - URL: https://landonmiles.com/blog/avoiding-hype - Published: 2025-12-02 - Updated: 2026-07-11 - Category: Technical Marketing - Tags: technical-marketing, product-marketing, communication ### Distrust is the default "Next-generation, AI-powered, enterprise-grade platform for synergy." If you work in tech, you've read some version of that sentence a thousand times. And if you're like most technical buyers, you stopped reading before you got to "synergy." When a product description is that dense with superlatives, it usually tells you one thing about the company writing it: they can't explain what the product does. The pressure in marketing is always to go bigger. Founders want disruption. Sales wants a silver bullet. But when your buyer is an engineer, a developer, or an IT admin, hype doesn't just fall flat – it costs you. They spend their days finding edge cases and breaking things on purpose. When you lead with "revolutionary," they read that as "something we can't back up." What works instead is boring: specifics, evidence, and plain description. --- ### Spotting the patterns Hype tends to hide in a few places. Once you start looking, you see it everywhere – including, sometimes, in your own drafts. - **Vague superlatives.** "Best-in-class," "unparalleled," "revolutionary." They take up space without adding information. - **Buzzword stacking.** "AI-driven blockchain synergy." It sounds expensive. It means nothing. - **Cherry-picked metrics.** "10x faster" – great, on what hardware, against what baseline, running what workload? #### What engineers hear Whenever I catch myself reaching for a stock marketing phrase, I try to imagine how it lands on a skeptical reader. This is roughly the translation layer running in their head: | **The Claim** | **What the Engineer Hears** | | :--- | :--- | | "Seamless integration" | "We haven't finished the API docs." | | "Zero-configuration" | "Good luck customizing this when it breaks." | | "Single pane of glass" | "An iframe that loads four dashboards slowly." | | "Unlimited scale" | "We haven't tested this past 1,000 users." | None of that is fair, exactly. But it's what technical buyers have learned to expect, because they've been burned before. --- ### Why it backfires Engineers verify claims. That's the job. If you promise "instant deployment" and it takes four hours to configure IAM, you haven't just annoyed a user. You've given them a reason to doubt everything else you've written, including the parts that are true. Once someone mentally files your content under "marketing fluff," it's hard to get out of that folder. Hype also attracts the wrong prospects. People who bought the dream churn when they meet the software. And every overpromise eventually shows up as a support ticket – the kind that starts with "your site said…" Writing honestly narrows the funnel, and that's usually what you want. You don't need every prospect. You need the ones who are going to succeed with the product. --- ### How to write it instead Honest copy doesn't have to be dry. It just means trading adjectives for specifics. #### Lead with the problem Don't open with "our AI tool." Open with "manually parsing 10,000 log lines is miserable." When you describe the user's pain accurately, they trust that you understand the solution. #### Anchor benefits to context "Fast" is a word. A useful performance claim names the operation, data shape and size, software version, hardware, baseline, and test method. If you don't have that evidence yet, go get it before you publish. #### Say what it doesn't do This is the hardest one to get past leadership, and the single most effective thing for buyers. "Works best for teams managing fewer than 500 endpoints." "Supports AWS and Azure; GCP is on the roadmap." Admitting the edges of your product is the fastest way to get a technical reader to believe the center. #### Show your work Link the methodology. Publish the benchmark script. Offer a sandbox. If the product is good, the best marketing asset you have is the product itself running in front of someone. --- ### When the pressure comes You will get asked to jazz it up. Someone will want "more punch" on a draft that is fine the way it is. The reframe that works for me: when a founder asks for more punch, offer more proof. Replace "revolutionary" with a customer quote about a measurable outcome. Replace "blazing fast" with a benchmark chart. The emotional register goes up, but the content stays verifiable. It also helps to point at competitors who already do this well. Stripe, Vercel, Tailscale, Fly – their marketing reads more like documentation than advertising, and it works. Not because dry is good for its own sake, but because their audience reads it and thinks, "okay, these people understand what I do." And when a feature genuinely isn't there yet, don't fake it. Label it as roadmap. Engineers understand development cycles. What they don't forgive is being lied to. --- ### A few things I do before I ship - **Highlight every adjective.** For each one, ask whether a number or a concrete noun would do more work. Delete or replace the ones that don't survive. - **Get an engineer to read it.** Not for typos – for plausibility. "Would you believe this if a competitor wrote it?" is a surprisingly useful question. - **Keep a banned-word list.** Mine has "seamless," "revolutionary," and "synergy" on it by default. Yours can be different. The point is that you've decided in advance what you won't say. If a claim can't survive "prove it," cut it or rewrite it. What's left is usually shorter, more specific, and more useful to the people you want as customers. --- ## The Key to Technical Marketing: Find Your LEGO Level - URL: https://landonmiles.com/blog/find-your-lego-level - Published: 2025-11-30 - Updated: 2026-07-28 - Category: Technical Marketing - Tags: technical-marketing, content-strategy, product-marketing ### The gap you're trying to close There's a tension built into any technical product. R&D and marketing are good at different things, and both are necessary. Engineering produces the product; marketing is how anyone finds out it exists and why they should care. But the two groups naturally operate from different instincts. Engineers reach for architecture, internals, and precision. Marketers reach for clarity, reach, and narrative. Those instincts don't line up on their own. Deep technical explanations overwhelm most buyers. High-level pitches leave technical buyers suspicious. And in the middle is a gap between what the product is and how customers understand what it does for them. Closing that gap is roughly what technical marketing is for. Your job is to translate technical depth into explanations of how the product solves a real customer problem, without oversimplifying to the point where an engineer in the room rolls their eyes. The frame I find useful for doing that is what I call the LEGO Level. --- ### What I mean by the LEGO Level Think about how LEGO sells its sets. The box doesn't show a pile of plastic bricks. It shows a race car, a spaceship, a castle. The pieces are present, but the thing you're being sold is what you can build with them. That split is the part worth stealing. A good LEGO Level for a piece of content sits at the altitude where the reader can see the outcome and the technical choices that affect it. Too high and it's a marketing brochure. Too low and it's an architecture doc no buyer will finish. Three questions help me find that altitude. #### What decision is the reader making? Features alone don't mean much to a buyer. They become useful when they're attached to a decision the reader needs to make. Before writing, pick a real scenario with a realistic environment and a clear before-and-after. Then work backwards from that outcome, so every feature you mention helps the reader evaluate, implement, or operate the product. #### Which internals change that decision? Internal details are useful when they change a buying decision, an implementation step, or the way someone will operate the product. Otherwise, they are probably in the wrong piece of content. Take PDF Comments. Saying it uses React says almost nothing about whether the tool fits. Saying the PDF is processed locally in the browser matters because it changes the privacy model. Saying the parser reconstructs highlighted text from page coordinates matters when you need to explain why unusual PDFs can produce imperfect results. My editing test is simple: does this detail change what the reader should choose, do, or expect? If not, cut it or move it to reference documentation. Architecture posts are useful; just don't disguise them as solution content. #### What job does the reader have right now? Readers do not divide neatly into two kinds of buyers. The same person can move through several jobs while considering and using a product: - **Evaluators** need to understand the problem, tradeoffs, requirements, and evidence. - **Implementers** need a sequenced path through configuration, integration, and validation. - **Operators** need references, troubleshooting guidance, and the limits they will meet in production. One page rarely serves all three jobs well. A product overview can help someone evaluate. A quickstart can guide the first implementation. API references and runbooks support the person operating it later. Problems start when a single article tries to be all of them at once. --- ### Turning it into a habit Nothing about this is a one-time exercise. A few things I try to do every time: - **Decide on the outcome before writing.** A real problem, a realistic environment, a clear before-and-after. If I can't name those, I'm not ready to write yet. - **Use the customer's language.** Internal feature names and project codenames creep into drafts. Replace them with whatever a new user would call the thing. - **Show the workflow.** Diagrams, short walkthroughs, and code samples that mirror a real task beat isolated feature descriptions almost every time. - **Move internals out of solution content.** If a paragraph is really about how something is built rather than how to use it, it belongs in a reference or architecture post. - **Publish more than one altitude.** Use product overviews for evaluators, step-by-step guides for implementers, and references for operators. The short version: stop describing the bricks. Start showing what gets built with them. --- ## Building Static Sites with AWS S3 and CloudFront - URL: https://landonmiles.com/blog/static-sites-and-aws - Published: 2025-11-29 - Updated: 2026-07-28 - Category: Development - Tags: aws, static-sites, deployment ### You already know how to build. The hard part is deploying it right. If you're a developer, running your own static site is one of the easiest ways to get something online while staying in your comfort zone – building, writing, and shipping. The harder part is putting it somewhere fast and reliable without signing up to manage a server. I keep coming back to AWS S3 paired with CloudFront. S3 stores the generated files. CloudFront serves them over HTTPS from edge locations and keeps the bucket off the public internet. It looks intimidating the first time and stops being so after you've done it once. Setup time depends on how familiar you are with AWS accounts, IAM, certificates, and DNS, so budget more than a quick copy-and-paste deployment the first time. One architecture choice matters before everything else: **this guide uses a private S3 REST origin with CloudFront Origin Access Control (OAC)**. It does not enable S3 website hosting. S3 website endpoints are public custom origins and [cannot use OAC](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html). The scope is deliberately narrow: static build output, a private S3 origin, one standard CloudFront distribution, and no request-time application code. Server-rendered pages, authentication, APIs, and other dynamic workloads need a different design. AWS console labels, plan eligibility, and prices also change; the linked AWS documentation is the source of truth for the current version. --- ### Why static sites – and why CloudFront + S3? Every static-site generator finishes with a folder of HTML, CSS, JavaScript, images, and other assets. The folder name changes, but the deployment pattern does not: | Generator | Common output directory | | :--- | :--- | | Next.js static export | `out/` | | Hugo | `public/` | | Astro | `dist/` | | Jekyll | `_site/` | | Eleventy | `_site/` | | Angular | `dist/` | For output made entirely of static files, AWS does not care which tool produced them. You build locally, upload that directory to S3, and put CloudFront in front of it. That gives you: - No application-server process or request-time runtime to patch - A private origin that viewers cannot bypass - Global caching and HTTPS - Deployments that can be reproduced from a terminal or CI runner - Storage and transfer costs based on use instead of a fixed server It works well for blogs, documentation, portfolios, marketing pages, and other sites whose request-time work can be moved into the build. --- ### Step 1: Build and inspect the output Run the production build and look at what it generated before touching AWS. ```bash title="Inspect the Static Export" pnpm build find out -maxdepth 2 -type f | sort | head -50 ``` Replace `out` with your generator's output directory. At minimum, confirm that the directory contains `index.html` and every asset the page references. Pay attention to how page files are laid out: | Public page | Flat output | Nested-index output | | :--- | :--- | :--- | | `/about` | `about.html` | `about/index.html` | | `/blog/post` | `blog/post.html` | `blog/post/index.html` | You will use that answer when you configure pretty URLs. Also look for intentional files without extensions: ```bash title="Find Extensionless Files" find out -type f ! -name '*.*' -print ``` An extensionless image or download needs two bits of care later: the URL rewrite must leave it alone, and the S3 object must have the correct `Content-Type` metadata. --- ### Step 2: Secure the AWS account The first login to a new AWS account is the root user. Root can change payment details, close the account, and bypass permissions you create later, so treat it as break-glass access. 1. Store the root password in a password manager. 2. Enable MFA on the root user. 3. Save the recovery method somewhere separate. 4. Do not create root access keys. AWS's own [root-user guidance](https://docs.aws.amazon.com/IAM/latest/UserGuide/root-user-best-practices.html) is worth reading once. After this setup, daily console and CLI access should use temporary credentials through IAM Identity Center. #### Pick the workload Region Choose an S3 Region near your audience or one that meets your regulatory requirements. There is no blanket reason to avoid `us-east-1`. CloudFront is global, but a few supporting services have fixed regional requirements. The important exception in this guide is the viewer certificate: an ACM certificate attached to CloudFront must be requested or imported in **US East (N. Virginia), `us-east-1`**, even if the S3 bucket lives somewhere else. #### Understand the new-account tradeoff first IAM Identity Center needs an **organization instance** to assign permission sets to an AWS account. An account instance can assign access to applications, but it cannot provide sign-in access to the AWS account itself. That has a billing consequence for newer accounts. If your account is on the AWS Free account plan, enabling an organization instance creates an AWS Organization, upgrades the account to the paid account plan, and ends its remaining Free Tier credits. AWS calls this out in both the [Identity Center setup documentation](https://docs.aws.amazon.com/singlesignon/latest/userguide/enable-identity-center.html) and the [Free Tier FAQ](https://aws.amazon.com/free/free-tier-faqs/). "Paid account plan" does not mean a monthly support subscription. It means the account can use the full service catalog and pays the normal rates for what it consumes. Still, losing credits is irreversible, so decide before clicking through. This guide assumes you are deploying a real site and choose the organization instance. #### Create and assign the Identity Center user 1. Open **IAM Identity Center** in the Region where you want Identity Center configured and enable the organization instance. 2. Create your user and complete the email activation. 3. Create an `Administrators` group and add the user. 4. Under **Multi-account permissions → Permission sets**, create a predefined `AdministratorAccess` permission set for initial setup. 5. Go to **AWS accounts**, select the management account, choose **Assign users or groups**, select the group, and then select the permission set. Creating a user and creating a permission set are not enough. The [account assignment](https://docs.aws.amazon.com/singlesignon/latest/userguide/assignusers.html) is what provisions the role and makes the account appear in the access portal. Save the AWS access portal URL, sign out of root, and confirm that the new user can enter the account. If you need billing access, create and assign the predefined `Billing` permission set as well and enable IAM access to Billing in the account settings. Once the site works, replace `AdministratorAccess` for routine deployments with a permission set scoped to this bucket and distribution. --- ### Step 3: Create the private S3 bucket Open S3 and create a **general purpose bucket**. I prefer one bucket per site because it makes policies and `sync --delete` much harder to aim at the wrong project. Use these settings: - **Region:** the workload Region you chose above - **Block all public access:** enabled - **Object Ownership:** Bucket owner enforced - **Default encryption:** SSE-S3 - **Object Lock:** disabled unless you have a specific retention requirement Every new S3 object is encrypted at rest with SSE-S3 by default. You can explicitly select it to make the intent clear. Do not enable an S3 Bucket Key with SSE-S3: Bucket Keys reduce KMS request costs and apply to **SSE-KMS**, not SSE-S3. AWS documents the distinction in its [default-encryption guide](https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-bucket-encryption.html). Do not enable **Static website hosting**. That creates a website endpoint, which would force CloudFront to treat S3 as a public custom origin. The regular S3 endpoint is what lets CloudFront authenticate with OAC. Leave the bucket empty for now. CloudFront can create the read policy after the distribution exists. --- ### Step 4: Create the CloudFront distribution Open CloudFront, choose **Create distribution**, give it a name, and choose **Single website or app**. That creates a standard distribution, which is the right shape for one site. #### Separate distribution type from pricing CloudFront now presents two choices that are easy to conflate: - **Standard distribution** describes the CloudFront resource. It is the single-site alternative to a multi-tenant SaaS distribution. - **Pay-as-you-go or a flat-rate pricing plan** describes how that distribution and related services are billed. Pay-as-you-go bills CloudFront, WAF, logging, and DNS separately and leaves WAF optional. Flat-rate plans come in Free, Pro, Business, and Premium tiers and bundle a distribution with allowances for CloudFront, WAF, bot management, Route 53, log ingestion, and S3 storage credits. AWS says there are no overage charges, although sustained use above an allowance can eventually change how traffic is delivered. For a low-traffic personal site, either pay-as-you-go without WAF or an eligible flat-rate Free plan can be reasonable. Read the live [flat-rate plan table and restrictions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/flat-rate-pricing-plan.html) instead of choosing by the word "free." The catches matter: - AWS accounts currently using the account-level Free Tier are not eligible for CloudFront flat-rate plans. - A flat-rate plan requires an attached WAF web ACL. - Some features, including real-time access logs and shared CloudFront Functions, are incompatible with flat-rate plans. - Lambda@Edge, some logging destinations and formats, and other features can still create separate charges. - On pay-as-you-go, WAF and Bot Control are separate metered services. #### Configure the origin On the origin page: 1. Choose **Amazon S3** as the origin type. 2. Use **Browse S3** and select the bucket – not a URL containing `s3-website`. 3. Choose **Use recommended origin settings**. 4. Confirm the origin uses **Origin access control settings (recommended)** and signs requests. The current wizard creates an OAC and updates the bucket policy. After creation, review the policy anyway. It should allow the `cloudfront.amazonaws.com` service principal to call `s3:GetObject`, with an `AWS:SourceArn` condition scoped to this distribution. AWS provides the exact [read-only OAC policy shape](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html). For the default behavior, use: - **Viewer protocol policy:** Redirect HTTP to HTTPS - **Allowed methods:** GET and HEAD - **Compression:** enabled - **Cache policy:** the recommended caching policy to start Set the distribution's **Default root object** to `index.html`. That setting handles `/` only. CloudFront explicitly [does not apply it to subdirectories](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html), which is why `/about/` still needs the function later. If the wizard asks about WAF security protections, make the choice that matches the pricing model above. On pay-as-you-go, the console shows a price estimate; leaving WAF off is a valid starting point for a private-origin static site with no forms, login, or API. You can add it after looking at real traffic. Create the distribution and wait until its status is deployed. Save both the distribution ID and its `d123example.cloudfront.net` domain. --- ### Step 5: Install AWS CLI v2 and configure SSO Use AWS CLI **version 2**. On macOS and Windows, use the official package installer linked from the [AWS CLI installation guide](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html). On Linux, AWS supports its bundled x86-64 and ARM installers plus the official Snap package; it does not guarantee that other distributions' repositories contain the latest version. For x86-64 Linux: ```bash title="Install AWS CLI v2 on x86-64 Linux" tmpdir="$(mktemp -d)" curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \ -o "$tmpdir/awscliv2.zip" unzip -q "$tmpdir/awscliv2.zip" -d "$tmpdir" sudo "$tmpdir/aws/install" rm -rf "$tmpdir" aws --version ``` For ARM Linux, use the `awscli-exe-linux-aarch64.zip` package shown in the same AWS guide. For a production workstation, follow AWS's signature-verification steps before installing the download. Now configure the named SSO profile you will use for this site: ```bash title="Configure the AWS SSO Profile" aws configure sso ``` The prompts ask for: | Prompt | Value | | :--- | :--- | | SSO session name | A local label, such as `personal-aws` | | SSO start URL | The access portal URL saved earlier | | SSO Region | The Region where IAM Identity Center is configured | | Registration scopes | Accept `sso:account:access` | | AWS account and role | Select the assigned account and permission set | | Default client Region | Your S3 workload Region | | CLI profile name | A clear name, such as `personal-site` | Authenticate and confirm the identity before deploying: ```bash title="Verify the AWS Session" aws sso login --profile personal-site aws sts get-caller-identity --profile personal-site ``` --- ### Step 6: Deploy the build Set variables once so the same directory, bucket, distribution, and profile appear in every command: ```bash title="Set Deployment Variables" export BUILD_DIR="out" export SITE_BUCKET="your-site-bucket" export DISTRIBUTION_ID="E123EXAMPLE" export AWS_PROFILE="personal-site" ``` Change `BUILD_DIR` if your generator uses `dist`, `public`, or `_site`. Then preview the sync: ```bash title="Preview the S3 Sync" test -f "$BUILD_DIR/index.html" aws s3 sync "$BUILD_DIR/" "s3://$SITE_BUCKET/" \ --delete \ --dryrun \ --profile "$AWS_PROFILE" ``` `--delete` removes remote files that no longer exist in the build. That is useful for a dedicated site bucket and dangerous if you point at a shared bucket. Read the dry run, then remove `--dryrun`: ```bash title="Upload the Static Export" aws s3 sync "$BUILD_DIR/" "s3://$SITE_BUCKET/" \ --delete \ --profile "$AWS_PROFILE" ``` The AWS CLI infers metadata from file extensions. If the earlier scan found an extensionless asset, upload it again with its real MIME type. For example: ```bash title="Set Metadata on an Extensionless PNG" aws s3 cp "$BUILD_DIR/icon" "s3://$SITE_BUCKET/icon" \ --content-type "image/png" \ --profile "$AWS_PROFILE" ``` Open `https://d123example.cloudfront.net/` and a direct file such as `/about.html` or `/about/index.html`. Clean page URLs come next. --- ### Step 7: Add one pretty-URL function A private S3 REST origin serves object keys. It does not turn `/about` into `about.html`, and CloudFront's default root object does not turn `/about/` into `about/index.html`. A viewer-request CloudFront Function can perform that mapping before CloudFront checks its cache or asks S3. The function must know which layout your generator produced; it cannot inspect the bucket to guess. In CloudFront, open **Functions**, create a function named `pretty-urls`, and paste this: ```javascript title="CloudFront Function – Pretty URLs" var OUTPUT_STYLE = 'flat'; // Use 'flat' or 'nested'. // Add every real object that intentionally has no file extension. var EXTENSIONLESS_FILES = { '/icon': true, '/apple-icon': true }; function handler(event) { var request = event.request; if (request.method !== 'GET' && request.method !== 'HEAD') { return request; } var uri = request.uri; // The distribution's default root object handles the site root. if (uri === '/') { return request; } // Treat /about and /about/ the same, and avoid double extensions. var cleanUri = uri.replace(/\/+$/, ''); if (EXTENSIONLESS_FILES[cleanUri]) { request.uri = cleanUri; return request; } var lastSegment = cleanUri.substring(cleanUri.lastIndexOf('/') + 1); if (lastSegment.indexOf('.') !== -1) { request.uri = cleanUri; return request; } if (OUTPUT_STYLE === 'nested') { request.uri = cleanUri + '/index.html'; } else { request.uri = cleanUri + '.html'; } return request; } ``` Set `OUTPUT_STYLE` to `flat` for `about.html` output or `nested` for `about/index.html` output. Replace the example extensionless-file list with the objects your build produced. If you forget one, a request for `/icon` becomes `/icon.html` or `/icon/index.html` and fails even though the object exists. Use the function's test tab for `/about`, `/about/`, an asset such as `/styles.css`, every extensionless object, and a missing page. Then: 1. Save and publish the function. 2. Open the distribution's default behavior. 3. Under **Function associations**, attach it to **Viewer request**. 4. Save and wait for the distribution update to deploy. Both `/about` and `/about/` now resolve to the same S3 object without changing the URL in the browser. This is a rewrite, not a redirect, so canonical metadata and consistent internal links still need to name the public form you want search engines to keep. If you require one visible URL, return a redirect for the alternate form instead. --- ### Step 8: Configure real 404s Most generators emit a `404.html` file. Upload it with the rest of the build, then open the distribution's **Error pages** tab and create two custom error responses: | Origin error | Response page path | Viewer response | Error-cache minimum TTL | | :--- | :--- | :--- | :--- | | 403 | `/404.html` | 404 | 10 seconds | | 404 | `/404.html` | 404 | 10 seconds | Map both. With a private S3 origin that can read objects but cannot list the bucket, a missing key commonly arrives at CloudFront as 403. Other origins or configurations can return 404. Keep the viewer response code at 404 – returning the error page as 200 creates soft 404s. CloudFront lets you choose the page, response code, and error-cache TTL in its [custom error response settings](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesErrorPages.html). Remember that a 403 can also mean a broken bucket policy or a WAF block, so troubleshoot a sudden wave of them instead of assuming every one is a missing page. Now test the full path matrix: ```bash title="Check CloudFront Responses" curl -I https://d123example.cloudfront.net/ curl -I https://d123example.cloudfront.net/about curl -I https://d123example.cloudfront.net/about/ curl -I https://d123example.cloudfront.net/styles.css curl -I https://d123example.cloudfront.net/icon curl -I https://d123example.cloudfront.net/definitely-not-a-page ``` Expect 200 for real pages and assets, the correct `Content-Type` for each asset, and 404 for the last request. Keep this matrix in a post-deployment smoke test; a missing or unpublished function association can make clean URLs fail even when the S3 objects are present. --- ### Step 9: Add the custom domain and certificate If the domain is in Route 53, the current CloudFront wizard can automate part of this. For any other DNS provider, the manual path is still short: 1. Switch the AWS console to **US East (N. Virginia), `us-east-1`**. 2. In AWS Certificate Manager, request a public certificate for the domain names you will use, such as `example.com` and `www.example.com`. 3. Add ACM's DNS validation records at your DNS provider and wait for the certificate to become issued. 4. Add those names as alternate domain names on the CloudFront distribution and select the certificate. 5. Point DNS at the CloudFront domain. Use a CNAME for a subdomain or your provider's alias/flattening feature for the apex domain. The regional requirement is easy to miss: CloudFront only accepts ACM viewer certificates from [`us-east-1`](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html). The S3 bucket does not need to move there. Test both HTTP and HTTPS after DNS propagates. HTTP should redirect to HTTPS, and the certificate should cover every hostname that reaches the distribution. --- ### Step 10: Add logging and bot controls deliberately Neither feature needs to be enabled just because the console offers it. #### Standard logging CloudFront [standard logging v2](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/standard-logging.html) can send selected fields to CloudWatch Logs, Firehose, or S3. For a small site, S3 is usually the least complicated destination. If you enable it: - Use a separate private log bucket or prefix. - Select only fields you expect to use. - Leave out `c-ip`, `x-forwarded-for`, cookies, query strings, and referrers if you do not need them. - Add an S3 lifecycle rule so old logs expire; CloudFront does not delete them for you. - Check the pricing for delivery, ingestion, storage, conversion, and queries. There is no generic "anonymize IPs" switch to rely on. Standard logging v2 gives you field selection, so data minimization is the cleaner option. #### WAF and bots On a flat-rate plan, the plan requires and bundles a WAF web ACL within its documented limits. On pay-as-you-go, WAF rules, requests, CAPTCHA, and Bot Control can add charges; the CloudFront security screen provides an estimate. If you enable Bot Control, start in monitor mode and look at the categories before blocking anything. AWS says verified common bots, such as validated search crawlers, are not subject to unverified-category actions. Unverified traffic is not automatically malicious, though: feed readers, link checkers, uptime monitors, command-line clients, and accessibility tools can all look non-browser-like. That is why I would not begin by blocking every data-center request or CAPTCHA-challenging every non-browser user agent. Use `robots.txt` for cooperative crawlers, a rate-based WAF rule for demonstrated abuse, and category actions only after you know what they catch. AWS documents both the [monitor-first WAF setup](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/WAF-one-click.html) and the fact that [Bot Control adds charges](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/WAF-one-click.html#enable-bot-control). --- ### Step 11: Deploy updates and invalidate the cache The normal update loop is build, verify, sync, invalidate: ```bash title="Deploy a Site Update" set -euo pipefail pnpm build test -f "$BUILD_DIR/index.html" aws sso login --profile "$AWS_PROFILE" aws s3 sync "$BUILD_DIR/" "s3://$SITE_BUCKET/" \ --delete \ --profile "$AWS_PROFILE" # Repeat any extensionless-asset metadata uploads here. aws cloudfront create-invalidation \ --distribution-id "$DISTRIBUTION_ID" \ --paths '/*' \ --profile "$AWS_PROFILE" ``` The `test` keeps a failed or misconfigured build from syncing an empty directory with `--delete`. The invalidation tells CloudFront to fetch changed objects instead of waiting for their cached copies to expire. If the build contains extensionless assets, keep their `aws s3 cp` metadata commands from Step 6 between the sync and invalidation on every deployment. A later sync that reuploads one of those objects can replace its metadata. As the site grows, you can give hashed assets long immutable cache lifetimes, keep HTML on a shorter policy, and invalidate fewer paths. Start with the understandable version first. Useful checks: ```bash title="Inspect the Deployment" aws s3 ls "s3://$SITE_BUCKET/" --recursive --profile "$AWS_PROFILE" aws cloudfront get-distribution \ --id "$DISTRIBUTION_ID" \ --profile "$AWS_PROFILE" aws cloudfront list-invalidations \ --distribution-id "$DISTRIBUTION_ID" \ --profile "$AWS_PROFILE" ``` --- ### Wrapping up Once this is in place, deploying a change is three operations: build, sync, invalidate. There is no application-server process to patch, but the site still needs monitoring for failed deployments, certificate and DNS problems, availability, and unexpected spend. The parts worth remembering are the parts AWS's wizard can hide: use a regular private S3 origin with signed OAC requests, inspect your generator's output before writing URL rewrites, map both 403 and 404 errors, and request the CloudFront certificate in `us-east-1`. Everything after that is repetition you can safely turn into a script or CI workflow. ---