Do you really need WordPress? Migrate your blog to S3 + CloudFront with Claude in under an hour
Translated from the Spanish original. Read in Spanish
WordPress, Drupal, Joomla — they are mature platforms and they solve a lot of cases. And yes, for a site with users, forms, e-commerce or an editorial team, they still make sense. But if all you do is publish articles, you’re paying a high price for things you don’t use: PHP, a database, plugins that need patching and an admin panel exposed to the internet.
The alternative is old and boring: static HTML in an S3 bucket, served by CloudFront. What changed is the cost of the migration. With Claude Code, the full migration — content, infrastructure in stage and prod, pipelines — took me between 30 and 50 minutes.
For reference: I estimate that a senior dev who knows AWS, doing the same by hand for a 50-post site, needs 3 to 5 working days — some 20 to 35 hours. Most of it isn’t the infra, but converting and cleaning up the content post by post and replicating the theme. If they already have the infra in Terraform or CDK, it drops to a day and a half or two. It’s still a different scale.
Here’s the step by step, with the technical details and — above all — the security model.

Do you need a CMS? The trade-offs
Before migrating, it’s worth being honest about what you lose and what you gain.
What you gain:
- Almost zero attack surface — no PHP, no database, no
/wp-admin. Nothing runs on the server side, so there’s nothing to exploit. - Zero maintenance — no more core, plugin or theme updates. A static site from five years ago still works the same.
- Cost — for a small blog, cents per month. CloudFront has a free tier of 1 TB of transfer per month.
- Performance — everything is served from the CloudFront edge. No render per request.
- Real versioning — the whole site lives in Git. Every change has history, review and rollback.
What you lose:
- Comments — solved with an external service (Giscus, for example), or removed. I removed them.
- Forms — you need a separate endpoint (Lambda, Formspree). It adds a moving part.
- Search — client-side only (Pagefind, Lunr). It works well up to a few thousand posts.
- The visual editor — you write in Markdown. If that’s not your thing, further down I show how Claude solves that part.
If the list of what you lose doesn’t hurt, keep reading.
The architecture
The final design is simple:
- GitHub repo — the content in Markdown and the static site generator (I use Astro).
- GitHub Actions — a pipeline that builds and publishes to stage on every push to
main, and to prod after a manual approval. - S3 — two private buckets, one per environment. No website hosting, no public access.
- CloudFront — one distribution per environment, with Origin Access Control (OAC) to read from the bucket, an ACM certificate and a CloudFront Function for the URLs.
- IAM — two different identities, with different permissions: a temporary one for Claude to do the migration, and one role per environment for the pipeline, via OIDC.
The security model, before touching anything
The original idea was to give Claude an access key, create the repo, create the pipelines and be done. It works. But on review three problems show up, and I fixed them before starting:
- Claude can’t have IAM permissions. If Claude’s user can create roles and policies, it can grant itself any permission — it’s privilege escalation by design. You create the OIDC provider and the pipeline roles yourself.
- The pipeline doesn’t use access keys. No
AWS_ACCESS_KEY_IDin GitHub Secrets. GitHub Actions authenticates against AWS with OIDC and gets temporary credentials, and the trust policy restricts which repo and which environment can assume each role. - Claude can’t modify the pipeline. If the agent can edit
.github/workflows/, it can change where things are published and with which permissions. You commit the workflows yourself, and Claude’s token has no permission to touch them.
The rule I apply: the agent operates within the limits; it never defines the limits.
Step by step
1. Create the IAM user for Claude
In the IAM console, create a user without console access — for example claude-migration. Attach this inline policy. Replace mi-blog with the prefix of your buckets:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SiteBucketsOnly",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:PutBucketPolicy",
"s3:GetBucketPolicy",
"s3:PutBucketPublicAccessBlock",
"s3:GetBucketPublicAccessBlock",
"s3:PutBucketOwnershipControls",
"s3:PutEncryptionConfiguration",
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::mi-blog-*",
"arn:aws:s3:::mi-blog-*/*"
]
},
{
"Sid": "CloudFront",
"Effect": "Allow",
"Action": [
"cloudfront:CreateDistribution",
"cloudfront:UpdateDistribution",
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudfront:ListDistributions",
"cloudfront:CreateOriginAccessControl",
"cloudfront:GetOriginAccessControl",
"cloudfront:ListOriginAccessControls",
"cloudfront:CreateFunction",
"cloudfront:UpdateFunction",
"cloudfront:DescribeFunction",
"cloudfront:TestFunction",
"cloudfront:PublishFunction",
"cloudfront:CreateResponseHeadersPolicy",
"cloudfront:ListResponseHeadersPolicies",
"cloudfront:ListCachePolicies",
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation"
],
"Resource": "*"
},
{
"Sid": "Certificates",
"Effect": "Allow",
"Action": [
"acm:RequestCertificate",
"acm:DescribeCertificate",
"acm:ListCertificates"
],
"Resource": "*"
}
]
}
Three things to note:
- S3 is scoped by ARN — Claude can only touch buckets that start with your prefix. There’s no
s3:DeleteBucket: deleting a bucket is your decision. - CloudFront uses
"Resource": "*"— creating distributions doesn’t support resource-level restriction. It’s the broadest point of the policy, and it’s the reason the key only lives during the migration. - There’s no IAM action at all. Deliberately.
If an AccessDenied shows up during the migration, Claude will tell you which action is missing. Add just that one. Don’t put cloudfront:* to unblock it.
Create the user’s access key and configure it in its own profile, not the default one:
aws configure --profile claude-migration
export AWS_PROFILE=claude-migration
aws sts get-caller-identity
Never paste the key into the chat with Claude. The agent uses the profile; it doesn’t need to see the secret.
2. Create the repo and limit what Claude can do
Create the repo on GitHub (private, even if the site is public — there’s no need to expose drafts). Then generate a fine-grained personal access token:
- Repository access — only this repo.
- Contents — read and write.
- Pull requests — read and write.
- Workflows — no access. That way Claude can’t push changes to
.github/workflows/. - Expiration — 7 days.
On the Claude Code side, add a .claude/settings.json to the repo with permission rules:
{
"permissions": {
"deny": [
"Read(~/.aws/**)",
"Bash(aws iam:*)",
"Bash(aws s3 rb:*)"
],
"ask": [
"Bash(aws cloudfront create-distribution:*)",
"Bash(aws cloudfront update-distribution:*)",
"Bash(aws s3 sync:*)",
"Bash(git push:*)"
]
}
}
Bear in mind this is a second layer, not the main one. A deny on Read doesn’t prevent a cat from Bash. The real control is the IAM policy from the previous step. The Claude Code rules make sure the important operations go through your approval.
3. Migrate the content: Claude reads WordPress via MCP
There’s no export here, no XML, no conversion scripts. I install the AI Engine plugin (by Meow Apps) on the WordPress site, which exposes an MCP server, and connect Claude directly to the site. Claude reads all the posts, categories and tags, downloads the images and builds the new site. Claude does all of it.
For authentication I use OAuth, not a static Bearer Token. The difference matters:
- No shared secret — there’s no token to copy, paste into a command and have end up in the shell history or a config file.
- Real identity — each authorisation is tied to a WordPress user, with a normal login and a consent screen that shows which app is requesting access and with which role.
- One-click revocation — AI Engine’s Connected Apps panel lists every active authorisation.
In WordPress, AI Engine → Settings → MCP: enable the MCP server. With OAuth there’s no need to define a Bearer Token — AI Engine publishes the discovery endpoints (/.well-known/oauth-*) and the client registers itself via Dynamic Client Registration. From the repo:
claude mcp add --transport http wp-origen https://mi-sitio.com/wp-json/mcp/v1/http
Then, inside Claude Code, run /mcp, choose wp-origen and Authenticate. The browser opens, you log in to WordPress and approve. The token is kept in Claude Code’s secure storage, not in a file in the repo.
And here’s the distinction that’s most often overlooked: with OAuth, Claude has the permissions of the user you log in with. If you approve as admin, Claude is admin — it can delete posts, change settings, install plugins if that group is exposed. OAuth improves authentication; it doesn’t narrow authorisation. That’s why:
- Create a dedicated user — for example
claude-migracion, with the lowest role that lets it read what you need. Don’t approve with your admin account. - Expose only what’s needed — on the MCP screen, leave only the WordPress group enabled (posts, media, taxonomies). Plugins, Themes and Dynamic REST, off.
- Check the WAF — some hosting providers block POSTs to
/wp-json/mcp/v1/oauth/registerand registration fails. If that happens, you need to allow/.well-known/oauth-*and/wp-json/mcp/v1/*.
If your version of AI Engine requires an admin user for the MCP, the trade-off changes: a Bearer Token with access level Read-Only is restricted on the server side, and then it may be the safer of the two options. Check before choosing.
As a second layer, block the write tools on the Claude Code side. Add to .claude/settings.json:
{
"permissions": {
"deny": [
"mcp__wp-origen__wp_create_post",
"mcp__wp-origen__wp_update_post",
"mcp__wp-origen__wp_delete_post",
"mcp__wp-origen__wp_upload_media",
"mcp__wp-origen__wp_update_option"
]
}
}
The exact tool names depend on the plugin version — /mcp shows you the list. Block everything that writes. Claude only needs the read tools.
With the MCP connected, the prompt:
Conectate al MCP wp-origen y migrá el sitio a Astro en este repo.
- Leé todos los posts publicados, con sus categorías, tags y excerpt.
- Convertí cada post a Markdown en una content collection, con front
matter: title, date, slug, categories, tags, description.
- Mantené exactamente las mismas URLs que en WordPress.
- Bajá todas las imágenes referenciadas (incluida la featured image)
a public/images/ y reescribí las rutas.
- Convertí los shortcodes y bloques de Gutenberg a HTML limpio.
- Ignorá comentarios, drafts y páginas de sistema.
- No escribas nada en WordPress: sólo lectura.
- Antes de escribir código, armá un plan en PLAN.md y esperá mi OK.
Review the plan. Carefully. What breaks most at this stage is the URLs: if they change, you lose the accumulated SEO and every external link. In Astro, the route is defined by the structure of src/pages/ — for example src/pages/[year]/[month]/[slug].astro if your WordPress uses /%year%/%monthnum%/%postname%/. If the structure matches, there are no redirects to maintain.
Leave Astro’s build.format as directory (the default): it generates /mi-post/index.html, which is what the CloudFront Function from step 4 then resolves. And create src/pages/404.astro — the build turns it into 404.html.
To preview locally:
npm run dev
And here’s a risk that’s often overlooked: the content Claude is reading is untrusted. Old posts, HTML pasted from other sites, a spam comment that slipped through. A text with hidden instructions is a prompt injection vector — and with an MCP that can write to your live site, the possible damage is no longer just local. That’s why the write tools go in deny, not ask, and why the AWS permissions are minimal.
The alternative without MCP is the native export (Tools → Export, WXR format) plus a copy of wp-content/uploads. It doesn’t touch the live site, but you have to download and organise the files yourself. For a site with few posts, the MCP is quite a bit faster.
4. Ask Claude for the infrastructure
With the site building locally, ask Claude to create the infra for stage and prod. This is what needs to be in place, and what’s worth checking:
- Private buckets — Block Public Access enabled on all four flags, no static website hosting. CloudFront reads via the bucket’s REST endpoint, not the website one.
- Origin Access Control — not the legacy OAI. The bucket only accepts
s3:GetObjectfrom that specific distribution. - ACM certificate in
us-east-1— CloudFront only accepts certificates from that region, regardless of where the buckets are. Validation is via DNS. - Response headers policy — HSTS,
X-Content-Type-Options,X-Frame-Options,Referrer-Policy. The managed policy SecurityHeadersPolicy covers the basics. - Minimum TLS —
TLSv1.2_2021and redirect from HTTP to HTTPS.
The bucket policy that should end up in prod:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontOAC",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::mi-blog-prod/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
}
}
]
}
5. Connect GitHub to AWS via OIDC (you do this one)
This is the step I don’t delegate to Claude, because it requires IAM permissions. First, the identity provider (once per account):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
Then, one role per environment. The trust policy of the prod role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:TU_USUARIO/TU_REPO:environment:production"
}
}
}
]
}
The condition on sub is what enforces “only this repo can publish”. And it goes one step further: only a job running in the production environment of that repo can assume the role. Use StringEquals, not StringLike with wildcards — a repo:TU_USUARIO/* opens access to all your repos.
The stage role is identical, with environment:staging and pointing at the stage bucket.
The permissions of the prod role — only what the deploy needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::mi-blog-prod"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::mi-blog-prod/*"
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}
Here CreateInvalidation does support restriction by ARN. The pipeline can publish to one bucket and invalidate one distribution. Nothing else.
6. The stage and prod pipelines
In the repo, Settings → Environments, create two environments:
- staging — deployment branch:
mainonly. Variables:AWS_ROLE_ARN,BUCKET,DISTRIBUTION_ID,SITE_URL. - production — deployment branch:
mainonly, required reviewer: you. The same variables, with the prod values.
They’re variables, not secrets. A role ARN isn’t a secret — without your repo’s OIDC token it’s useless.
The workflow, in .github/workflows/deploy.yml, has three jobs:
- check — runs on every PR. Installs dependencies with
npm ciand builds. It doesn’t ask for AWS credentials. - deploy-stage — runs on every push to
main, in thestagingenvironment. Builds, assumes the stage role via OIDC, uploads the site withaws s3 sync --deleteand invalidates the CloudFront cache. - deploy-prod — depends on stage and runs in the
productionenvironment. The same steps, with the prod role and variables.
Two details that make the difference: the id-token: write permission is declared only on the deploy jobs, not at workflow level, and all three jobs read everything from the environment variables — the same code publishes to stage or prod depending on where it runs.
The flow ends up like this: a PR only builds — without AWS credentials, so a malicious PR can’t publish anything. A merge to main publishes to stage automatically. Prod waits for your approval in the Actions tab. You approve, and it publishes.
About invalidation: /* counts as a single path, and the first 1,000 paths per month are free. For a blog, invalidating everything on every deploy is the simplest option and costs nothing.
Building twice (once per environment) is deliberate: the site URL changes, and Astro embeds it in the HTML — canonical, sitemap, RSS. In astro.config.mjs:
export default defineConfig({
site: process.env.SITE_URL,
build: { format: 'directory' },
});
In the pipeline, SITE_URL comes from the environment variable. The cost is a few extra seconds of build.
7. Protect the pipeline
The pipeline is now the only path to prod. That makes it the asset to protect:
- Branch protection on
main— mandatory PR,checkstatus check green, no force push. - CODEOWNERS on
.github/— any change to the workflows requires your review. - Required reviewer on production — already configured in the previous step. It’s the last human control before publishing.
8. Cutover
With prod published and verified on the CloudFront URL (dxxxx.cloudfront.net):
- Lower the DNS record’s TTL to 300 seconds, a day ahead.
- Point the domain at the distribution: an alias record in Route 53, or a CNAME at your provider (for the apex, use ALIAS/ANAME if supported).
- Check that the old URLs respond. A simple script that walks the WordPress sitemap and compares HTTP codes is enough.
- Keep WordPress switched off — not deleted — for a couple of weeks, in case something slipped through.
9. Close everything you opened for the migration
First, the AWS key:
aws iam update-access-key \
--user-name claude-migration \
--access-key-id AKIAXXXXXXXXXXXXXXXX \
--status Inactive
And remove it from ~/.aws/credentials. An inactive key on disk is still a key waiting for someone to reactivate it.
My recommendation, in fact, is to go one step further: delete it. When you need one, you create a new one — it takes ten seconds and makes sure no old key is left forgotten. Also revoke the GitHub token, or let it expire.
Next, the MCP. If WordPress stays online during the transition period, revoke the authorisation in Connected Apps, disable the MCP in AI Engine and delete the claude-migracion user. If the plugin was only there for the migration, uninstall it. And on the Claude Code side:
claude mcp remove wp-origen
An MCP with write permissions on a site you no longer use is exactly the kind of thing that gets forgotten and left open.
Claude is your “visual” editor
Here’s what wins over people who want nothing to do with Markdown or Git. The WordPress panel disappears, and the editor becomes a conversation. You open Claude Code in the repo and tell it:
Creá un post nuevo. Este es mi draft:
[tu texto, bla bla]
Categoría Tecnología.
Levantá npm run dev para que lo revise.
Cuando te dé el OK, creá una branch, commiteá y abrí un PR.
You review at localhost:4321, ask for tweaks — “shorten the intro”, “move the image below the first paragraph”, “change the title” — and approve. Claude opens the PR, the check passes, you merge, stage updates itself, you approve prod. Done.
No AWS credentials are needed for this. Claude only needs the GitHub token — the pipeline does the deploy. It’s the setup I leave in place permanently.
A skill with your blog’s rules
The prompt above works, but it leaves a lot up to what Claude infers from the existing posts. The way to pin it down is a skill: a file with the rules for what a post must look like, which Claude loads every time you ask for one. It’s what an editor does — know the style guide without you having to repeat it.
It lives in the repo, in .claude/skills/nuevo-post/SKILL.md:
---
name: nuevo-post
description: Crea o edita posts del blog. Usar siempre que se pida
escribir, corregir o publicar un post.
---
# Nuevo post
## Front matter (obligatorio)
- title, date (ISO), slug en kebab-case, description (máx. 160 caracteres)
- categories: sólo Tecnología, Seguridad o AI
- tags: entre 2 y 5
## Estilo
- Español, primera persona, frases cortas.
- Términos técnicos en inglés, sin traducir.
- Apertura con el problema, no con una definición.
- Un H2 por sección. Nada de H1 en el cuerpo.
## Imágenes
- En public/images/[slug]/, formato webp, máx. 1600px de ancho.
- alt obligatorio y descriptivo.
- La principal va debajo de la intro.
## Flujo
1. Crear el archivo en src/content/blog/[slug].md como draft: true.
2. Levantar npm run dev y pasar la URL local.
3. Aplicar los cambios que pida.
4. Con el OK: draft: false, branch post/[slug], commit y PR.
Nunca pushear directo a main.
With the skill, the prompt boils down to “create a post from this draft”. The rest — format, images, categories, the flow up to the PR — is already defined.
The security review, summarised
These are the risks I reviewed, and where each one ends up:
- Long-lived access key — mitigated. It only lives during the migration, in a separate profile, and is deleted afterwards. Residual risk: the migration window, under an hour.
- Privilege escalation via IAM — eliminated. Claude’s user has no IAM action at all; you create the pipeline roles yourself.
- CloudFront policy with
Resource: *— accepted. It’s an API limitation. During the migration, Claude could modify other distributions in the account. If you have other distributions in production, do the migration in a separate AWS Organizations account. - Credentials in GitHub — eliminated. OIDC with temporary credentials, no secrets.
- Another repo publishing to your bucket — eliminated. The trust policy requires the exact repo and environment.
- Agent modifying the pipeline — mitigated. The token has no Workflows permission, and CODEOWNERS requires your review.
- Prompt injection from the imported content — mitigated, not eliminated. The blast radius is bounded by the permissions, and sensitive commands require your approval.
- Exposed bucket — eliminated. Block Public Access, no website hosting, OAC tied to one distribution.
s3 sync --deletedeleting too much — accepted. If a build comes out empty, it wipes the site. Enable versioning on the prod bucket: you can recover any previous version for cents.- MCP with write access to the live WordPress — mitigated. OAuth with a dedicated low-role user, only the WordPress group exposed, write tools in
deny, and the authorisation revoked and MCP switched off at the end. Residual risk: the endpoint is exposed to the internet while it’s active, and the real permissions are those of the user’s role. - Supply chain — mitigated. Actions pinned by SHA,
npm ciwith a committed lockfile.
Under an hour? It depends
For a blog with a few dozen posts, yes. Those 30–50 minutes are my time reviewing and approving; a good part of it isn’t Claude’s work but waiting: ACM certificate validation, the initial CloudFront deploy (several minutes) and DNS propagation. If the site has hundreds of posts with custom plugin shortcodes, add review time — the conversion will need more than one iteration.
This is one way to do it. The components are interchangeable — Astro for Hugo, AI Engine for the native export, GitHub Actions for another CI, Route 53 for your current DNS. What I wouldn’t change is the permission model: the agent with the minimum and for a limited time, the pipeline without keys, and a human approving what reaches prod.
