In the previous post, I wrote about the philosophy behind a no-ads personal blog that runs for just a few hundred yen a month.

This time it's the technical side. I want to document the key design decisions, focusing on why I chose this architecture. There's no code here, so think of it as an architecture reference for anyone who wants to build something similar.


Overall Architecture

In a nutshell, it looks like this.

[Operator] --aws s3 cp--> [S3 source/]
                            |
                            v
                  [Lambda: schedule]   ← EventBridge daily 09:20 JST
                            |              + Bedrock call
                            v
                       [S3 staging/]
                            |
                            v
                   [Lambda: publish]   ← EventBridge Monday 06:00 JST
                            |
                ┌───────────┼─────────────┐
                v           v             v
          [S3 blog/]  [S3 assets/]   [S3 archive/]
                |           |
                └─────┬─────┘
                      v
                [CloudFront] ──> [Readers]

Everything lives in S3. No database, no containers, no persistent servers.


Decision 1: Why I Didn't Use DynamoDB

When designing the blog system, the first question I asked was "where should article metadata live?" The natural instinct is to reach for DynamoDB or RDS.

But when I thought it through, the total number of articles would be a few hundred at most. That's a scale where S3's ListObjectsV2 can fetch everything in under a second.

In that case, keeping each article's metadata as a manifest.json right alongside it in S3 is far simpler.

archive/20260518-claude-code-basics/
├── claude-code-basics.html
├── manifest.json          ← everything about this article
├── _attachments/
│   └── screenshot.png
└── _source/
    └── claude-code-basics.md  ← original file

The manifest.json holds information like this.

{
  "title_kebab": "claude-code-basics",
  "title_display": "Claude Code 基礎",
  "publish_date_jst": "2026-05-18",
  "category": "tech",
  "ai": {
    "calls": [
      { "prompt": "convertToArticle", "model_id": "claude-sonnet-4-6", "input_tokens": 5586, "output_tokens": 2282 },
      { "prompt": "generateSlug",     "model_id": "claude-haiku-4-5",  "input_tokens": 1762, "output_tokens": 24 }
    ]
  },
  "stages": {
    "staged_at":   "2026-05-09T07:53:50+00:00",
    "published_at":"2026-05-11T06:00:13+00:00"
  }
}

When regenerating the top page or category pages, I just read all the archive/*/manifest.json files and aggregate them. That's all it takes.

The moment you bring in DynamoDB, you're constantly fighting the problem of data drifting out of sync between S3 and the DB. With manifest.json living right in S3, there's always exactly one source of truth.


Decision 2: Why I Used CloudFront Behaviors as a Security Boundary

This blog's bucket mixes "public folders" and "private folders" together.

s3://da-leca-blog/
├── source/    ← private (raw ideas)
├── staging/   ← private (pending publication)
├── archive/   ← private (history)
├── blog/      ← public
└── assets/    ← public

The conventional approach would be to use separate buckets for source/staging/archive. But then the article-generation Lambda would have to reach across multiple buckets, making IAM policies more complicated.

Instead, I went with a design where everything lives in one bucket, and CloudFront Behaviors explicitly enumerate what's public.

The CloudFront Behaviors are just these.

Path Pattern Origin
/blog blog bucket
/blog/* blog bucket
/assets/blog/* blog bucket
/sitemap.xml blog bucket
/robots.txt blog bucket

There's no Behavior for /source/* or /staging/*, so hitting those through CloudFront just returns a 404. Direct bucket access is blocked by S3 Block Public Access + OAC, so the result is a simple rule: "only paths listed in a Behavior are reachable."

No VPC, no security groups needed. I'm quite fond of the idea of "Behaviors as an allowlist."


Decision 3: Why I Chose Bedrock (Instead of Hitting the Anthropic API Directly)

If you're using Claude, the obvious move is to call the Anthropic API directly. The pricing is nearly identical to Bedrock.

I still went with Bedrock because IAM roles alone handle all the authentication.

No API key needs to live anywhere. Not in Secrets Manager, not in environment variables, not in Parameter Store. Just attach bedrock:InvokeModel to the Lambda execution role and Claude is callable.

# This just works (no credentials anywhere)
import boto3
client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
response = client.invoke_model(
    modelId="global.anthropic.claude-sonnet-4-6",
    body=json.dumps({...})
)

The overhead of managing API keys quietly adds up.

  • Where do you store it?
  • How do you rotate it?
  • What happens if it leaks?
  • What's the overhead at Lambda startup?

Eliminating all of that is a big deal. It's the principle of "the safest key is no key at all" in its purest form.


Decision 4: Why I Split EventBridge Into Two

I separated "turning raw ideas into articles" and "publishing articles" into distinct Lambdas with distinct schedules.

Lambda Schedule Job
schedule_lambda Daily 09:20 JST Picks a random idea from source and creates a draft in staging
publish_lambda Monday 06:00 JST Publishes staged articles to production

The reason I didn't merge them is that I wanted a window for human review.

Every morning at 9:20, schedule_lambda runs and I get a notification when something new lands in staging. If a draft feels off before the weekend is over, I can swap it out before Monday's publish.

In practice I almost never intervene, but knowing I can step in at any time is genuinely reassuring. Full automation looks elegant, but when there's no room for human judgment and something goes wrong, you're going to cry.


Decision 5: Designing for Idempotency From the Start

I built every Lambda on the assumption that it will be re-executed no matter what. EventBridge retries, manual invokes, CloudWatch manual re-runs — none of them should break anything.

Here are the specific measures I took.

Each staging entry is unique by directory

staging/20260518-claude-code-basics/

YYYYMMDD-slug is unique. Re-running the same thing just overwrites the same path.

Publish is just "copy the same file to the same path"

S3's CopyObject is idempotent. Run it as many times as you like — same result every time. The manifest's published_at just gets overwritten too.

Moving to archive is "copy + leave in place"

At publish time, staging is copied to archive, but the staging side isn't deleted until the very end. If something fails midway, staging is still there and the whole thing can be re-run.

Retrofitting idempotency is a nightmare, so I recommend asking "will this break on re-run?" for every function from day one.


Decision 6: Externalizing AI Prompts to AppConfig

The system prompts sent to Claude live in AWS AppConfig rather than being hardcoded.

prompts:
  convertToArticle:
    revision: v1.1
    system: |
      あなたはブログ編集者です...
  generateSlug:
    revision: v1.1
    system: |
      Generate a URL-friendly kebab-case slug...
  pickCategory:
    revision: v1.0
    system: |
      以下の記事をカテゴリ分けします...

Lambda fetches from AppConfig at startup and caches it with a 5-minute TTL.

The big win here is that tweaking a prompt doesn't require a CDK deploy.

Prompt tuning is a constant trial-and-error process — deploying every time would grind things to a halt. With AppConfig, changes take effect in seconds.

On top of that, the revision is recorded in the manifest, so I can later tell apart "articles generated with the v1.0 prompt" from "articles generated with v1.1." Being able to track changes in AI output quality is quietly important.


Things I Deliberately Didn't Do

  • Storing Markdown in a DB — keeping it as S3 files makes grep and copying way easier
  • A CMS UIaws s3 cp is fine
  • Diff-based version control for articles — snapshots in archive are enough; no Git
  • Multi-language support — extend the templates when needed ← later added English and Spanish
  • Auto-generating OGP images — add a separate Lambda when needed ← later added this
  • Prev / next links — can be added later via manifest extension, not there yet ← later added this

I was strict about "don't build what you don't need right now." Just make sure the design allows for it to be added later.


Who This Architecture Is Right For

  • A personal blog or one run by 1–2 people
  • Around 1–10 posts per month
  • No interest in running ads
  • Want to keep infrastructure costs under ¥1,000/month
  • Reasonably comfortable with AWS and Python

It's probably not right for:

  • A team blog with multiple simultaneous editors → use a headless CMS
  • A media site that needs real-time publishing → Lambda + S3 is inherently async
  • High-traffic sites serving dynamic content → CloudFront alone won't cut it

Closing Thoughts

The question I kept asking myself while building this blog was: "Do I actually need this?"

Adding features is easy; removing them is hard. What survived that process of subtraction is the system running right now.

S3 + CloudFront + Lambda + Bedrock. Just four managed services. That's all it takes to run a personal blog where AI turns notes into articles and publishes them automatically every Monday morning.

Hope it's useful to anyone thinking of building something similar.


Previous post: Building a "just drop files in" blog with no ads and a few hundred yen a month