The server is ninety seconds old, and the first thing it does is start deleting.
The sample plugin that ships with every new install. Gone.
The sample blog post. Gone.
The sample page. Gone.
Nobody has logged in yet. There is no browser open anywhere. This machine came up, read a script off the side of itself, and started stripping out the parts of itself it did not want.
Then it starts building.
It reaches into a private storage bucket, using a key that can do exactly one thing, read that one bucket, and nothing else, and it pulls down a licensed theme. Installs it. Installs the plugins that came with it.
Adds the search tooling. Adds an image delivery network. Adds a contact form.
And turns down everything else it was offered.
It sets the name of the site. It builds a menu and pins it to the top of the page.
And then it writes five pages.
Home.
Blog.
About.
Services.
Contact.
sudo wp post create --post_content="" --post_title="Home" --post-excerpt="" --post_type=page --post_author="1" --post_status="publish"
sudo wp post create --post_content="" --post_title="Blog" --post-excerpt="" --post_type=page --post_author="1" --post_status="publish"
sudo wp post create --post_content="" --post_title="About" --post-excerpt="" --post_type=page --post_author="1" --post_status="publish"
sudo wp post create --post_content="" --post_title="Services" --post-excerpt="" --post_type=page --post_author="1" --post_status="publish"
sudo wp post create --post_content="" --post_title="Contact" --post-excerpt="" --post_type=page --post_author="1" --post_status="publish"
Every one of them empty.
That is the whole idea of the day, sitting right there in five lines.
Every small business site on the internet starts as the same five pages. An auto shop, a law office, a bakery. The skeleton does not change. Only the words inside it do.
So the skeleton stopped being work. It got written down one time, and now a new client is two facts. A short handle, and the name they want on the sign.
variable "client_id" {
description = "Unique identifier for client"
type = string
default = "mmauto"
}
variable "site_name" {
description = "Human-readable name for site"
type = string
default = "2M Automotive"
}
Everything after that assembles itself while you go get coffee. The subdomain. The permanent address. The backup that runs every morning at six. The theme, the menu, the five empty pages, waiting.
The factory is finished.
The line that actually starts it is still commented out.
# module "mm" {
# source = "./2m"
# aws_access_key_id = aws_iam_access_key.client_files.id
# aws_access_key_secret = aws_iam_access_key.client_files.secret
#
# depends_on = [ aws_lightsail_key_pair.client ]
# }
The Most Interesting Decision
Welcome back.
Before the break I described a machine that builds an entire small business website by itself, in about ten minutes, from nothing — and then told you the line that starts it is commented out.
Both of those things are true, and the second one is not a failure. It's the most interesting decision in the whole day's work. But I want to earn my way to it, because to understand why that line is disabled you have to understand exactly how much is sitting behind it.
The Structure Does Not Change
So. The business problem first, because everything today follows from it.
This is a consultancy that builds websites for small local companies. The client in question is an automotive shop.
And if you've done this work you already know the shape of it. Every one of these engagements is the same engagement. The client needs a home page, somewhere to put news, a page about the company, a page listing what they do, and a way to get in touch. They need a content system they can log into without calling you. They need it to be fast, backed up, and on their own domain.
The words change. The photographs change. The structure does not change, ever, across an auto shop and a law office and a bakery.
Which means the setup is pure repetition. And repetition is the thing this project has spent every previous day learning how to delete.
Two Facts In
So here's the shape of the answer.
A new client is defined by two facts. A short handle, lower case, no spaces — the internal name. And the human-readable name, the one that goes on the sign.
That's it. That's the whole input. Everything else in this system is derived from those two strings.
The handle becomes the subdomain. The handle becomes the server's name, and the name of the reserved address, and the key that ties them together. The display name becomes the title of the site.
Two facts in, and a working website out. That's the pitch. Now let's look at whether it holds up.
Every Client Gets Their Own Machine
First decision, and it's a real fork in the road: where does the client's site actually run?
There is already a cluster in this project. It's up, it's got certificates, it's got twenty application addresses pointed at it. The obvious move is to run client sites there too.
That's not what happens. Every client gets their own dedicated virtual machine, on the provider's simplified hosting product — the one with a flat monthly price and a preinstalled software image. In this case, the smallest paid tier, running a stock WordPress build.
resource "aws_lightsail_instance" "wordpress" {
name = format("%s-wordpress", var.client_id)
availability_zone = "us-east-1a"
blueprint_id = "wordpress"
bundle_id = "micro_3_0"
key_pair_name = "wp-client"
}
And I think that's the right call, for reasons that have nothing to do with technology.
Client work has a property that internal work doesn't: it ends. Contracts finish, businesses close, somebody's nephew takes over the website. When a client on their own machine leaves, you delete one machine and one address and the invoice stops. When a client sharing a cluster leaves, you're carefully unpicking their workloads from everybody else's, at night, hoping you don't take down the auto shop next door.
Isolation costs a few dollars a month per client. Untangling costs a weekend and a difficult phone call.
A Fixed Monthly Bundle
There's a second reason, and it's the flat price.
A fixed monthly bundle means you can quote hosting to a small business without a spreadsheet. The invoice next month will be the same number as the invoice this month, regardless of whether the client's site had a good week.
That is worth a great deal when your customer is a person who owns an auto shop and does not want to have a conversation about egress charges.
The Machine's Address Isn't Stable
Now the naming, which is where today gets genuinely fiddly.
The client's site lives at their handle, under the consultancy's dot glass domain. So the automotive shop gets a subdomain, and that subdomain needs to resolve to their machine.
Simple enough. Except that the machine's address isn't stable.
Virtual machines get an address when they boot. Stop one and start it again — for a resize, a maintenance window, a bad afternoon — and it comes back with a different one. Which means the site is fine until the first time anything happens to it, and then it's offline, and you find out from the client.
So a permanent address is reserved separately, by name, and attached to the machine. The address now belongs to the account rather than the server. The machine can be rebuilt underneath it and the world outside never notices.
resource "aws_lightsail_static_ip" "ip" {
name = var.client_id
}
resource "aws_lightsail_static_ip_attachment" "ip" {
static_ip_name = aws_lightsail_static_ip.ip.id
instance_name = aws_lightsail_instance.wordpress.id
}
Registered in Two Places
And then there's a wrinkle I want to be honest about, because it's the one part of today that isn't clean.
The client's subdomain gets registered in two places. Once as a proper zone in the main naming service, and again inside the simplified hosting product, which has its own separate naming system with its own separate records.
resource "aws_route53_zone" "zone" {
name = format("%s.madeof.glass", var.client_id)
}
resource "aws_lightsail_domain" "domain" {
domain_name = format("%s.madeof.glass", var.client_id)
}
Both exist. Both describe the same name. And a record is written to hand delegation from one to the other — the parent zone pointing at four name servers, so that lookups end up in the right place.
Those four name server names are typed in as literal strings.
Which is the tell. When a value is hand-copied rather than referenced, it's because the tool couldn't hand it to you — you had to go read it off a screen and paste it in. And it works, but it's a value that will be correct until the day it isn't, with nothing to warn you.
There's also a flag on that record explicitly permitting it to overwrite whatever was there before. That's a sharp edge, deliberately unsheathed. Somebody hit a conflict, understood why, and decided the automation should win.
resource "aws_route53_record" "ns" {
zone_id = aws_route53_zone.zone.zone_id
name = aws_route53_zone.zone.name
type = "NS"
ttl = 30
records = [
"ns-1202.awsdns-22.org",
"ns-1913.awsdns-47.co.uk",
"ns-630.awsdns-14.net",
"ns-126.awsdns-15.com",
]
allow_overwrite = true
}
Six in the Morning
Backups, briefly, because it's one line and it's the kind of one line that saves a business.
The machine has automatic snapshots enabled, taken every morning at six.
Six in the morning is chosen, I'd assume, because it's after the overnight batch of nothing and before anybody in a small business is awake to be editing pages. The worst case is that you lose a day of edits made by a client who is asleep.
It's one line. It gets turned on at creation, for every client, forever, without anybody having to remember. That's the entire argument for writing infrastructure down instead of clicking it.
add_on {
type = "AutoSnapshot"
snapshot_time = "06:00"
status = "Enabled"
}
The Factory Instead of a Template
Right. Now the part that makes this a factory instead of a template.
Attached to the machine's definition is a startup script. The provider hands it to the machine on first boot, and the machine runs it as root, once, before anybody has ever logged in.
And this script does not set up a web server or install WordPress — those are already there, baked into the image. What it does is take a generic, stock, out-of-the-box installation and turn it into this consultancy's installation.
It Starts by Deleting
It starts by deleting.
The sample plugin that ships with every install, the one that's been shipping since two thousand and seven and does nothing. Gone. The sample blog post. The sample page. Both deleted by their identifiers, which are always the same two numbers in every fresh install on earth.
rm stack/wordpress/wp-content/plugins/hello.php
sudo wp post delete 1
sudo wp post delete 2
That's a small thing that tells you this was written by someone who has done the manual version enough times to have memorized the cleanup.
Get Object
Then it goes shopping — and this is where today's other major piece of work comes in.
The consultancy uses a commercial theme. Licensed, purchased, and specifically not something you can pull from a public repository. So it has to be stored somewhere private and fetched at build time.
Today, that somewhere gets created: a private storage bucket holding the theme package.
resource "aws_s3_bucket" "client_files" {
bucket = "wordpress-client-files"
}
And with it, a dedicated account whose entire existence is to read that bucket.
I want to read you what that account is permitted to do, because it's four words long.
Get object. On the contents of one named bucket.
It cannot list what's in the bucket. It cannot write to it, delete from it, or change who can see it. It cannot touch any other bucket. It cannot see a single other service in the account.
If you handed those two strings to a stranger, the total extent of what they could do is download a theme they'd have to already know the filename of.
resource "aws_iam_user_policy" "client_files_access" {
name = "client_files_access"
user = aws_iam_user.client_files.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"s3:GetObject"
]
Effect = "Allow"
Resource = "arn:aws:s3:::wordpress-client-files/*"
}
]
})
}
And it's tagged, on creation, with a label marking it as belonging to client infrastructure rather than core infrastructure. Which sounds like paperwork until the day someone asks "what does this account actually touch?" and the answer is a filter instead of an afternoon.
resource "aws_iam_user" "client_files" {
name = "wordpress_installer"
path = "/limitless/"
tags = {
Limit = "Client"
}
}
resource "aws_iam_access_key" "client_files" {
user = aws_iam_user.client_files.name
}
Make the Key Boring
That narrowness is doing real work, because of where those credentials end up.
The startup script writes them to disk on the client's machine, into the standard credentials file, so the command line tool can find them. They live there afterward. The script also exports them into its own environment while it runs.
tee .aws/credentials << END
[default]
aws_access_key_id = ${var.aws_access_key_id}
aws_secret_access_key = ${var.aws_access_key_secret}
END
export AWS_ACCESS_KEY_ID=${var.aws_access_key_id}
export AWS_SECRET_ACCESS_KEY=${var.aws_access_key_secret}
export AWS_REGION=us-east-1
So this credential is going to be sitting on every client server this factory ever produces. Handed to machines that get rebuilt, snapshotted, and eventually handed off.
Which is exactly why it can do one thing. The design isn't "keep the key safe." The design is "make the key boring." Those are different strategies, and the second one is the one that survives contact with reality.
A Portrait of Professional Taste
Then the script installs. And the list of what it installs is a portrait of professional taste.
The licensed theme comes down from the private bucket and gets activated. Five companion plugins that ship inside that theme follow — a page builder, the theme's core, its shortcodes, its slider, its social tooling. Then a search and analytics integration.
aws s3 cp s3://wordpress-client-files/salient-theme.zip installer/salient-theme.zip
sudo wp theme install installer/salient-theme.zip --activate
sudo wp plugin install stack/wordpress/wp-content/themes/salient/plugins/js_composer_salient.zip --activate
sudo wp plugin install stack/wordpress/wp-content/themes/salient/plugins/salient-core.zip --activate
sudo wp plugin install stack/wordpress/wp-content/themes/salient/plugins/salient-shortcodes.zip --activate
sudo wp plugin install stack/wordpress/wp-content/themes/salient/plugins/salient-nectar-slider.zip --activate
sudo wp plugin install stack/wordpress/wp-content/themes/salient/plugins/salient-social.zip --activate
sudo wp plugin install google-site-kit --activate
Then everything gets updated. Plugins and themes both, immediately, before anybody logs in.
sudo wp plugin update --all
sudo wp theme update --all
Which matters more than it sounds. A stock image is only current on the day it was published. Every machine built from it is born a few weeks behind. Running the updater at boot means the site is current the moment it exists rather than the first time somebody remembers.
Five Get Enabled
And then there's the hosting platform's bundled feature suite, which arrives with a long menu of things it would like to turn on.
Five get enabled. A contact form. Web fonts. Image delivery through a content network. Search engine tooling. Site verification tooling.
And roughly the same number are sitting right there, explicitly written out, and commented off. Promotional tooling. A remote programming interface. Uptime monitoring. Notifications. Site statistics. Brute force protection.
# sudo wp jetpack module activate blaze
sudo wp jetpack module activate contact-form
sudo wp jetpack module activate google-fonts
# sudo wp jetpack module activate json-api
# sudo wp jetpack module activate monitor
# sudo wp jetpack module activate notes
# sudo wp jetpack module activate photon
sudo wp jetpack module activate photon-cdn
# sudo wp jetpack module activate protect
sudo wp jetpack module activate seo-tools
# sudo wp jetpack module activate stats
sudo wp jetpack module activate verification-tools
That's not a list of things nobody got to. That's a list of decisions. Somebody went down the menu line by line and made a call on each one, then left the rejected options visible so the next person can see the reasoning rather than wondering whether they were missed.
I'd guess the pattern is: anything that speeds up the client's site, yes. Anything that duplicates something already handled elsewhere — monitoring, statistics, protection — no. But the guess matters less than the fact that the choices are legible at all.
Completely Empty
Finally the script builds the site itself, and it takes about six lines.
It sets the site's name from the client variable. It sets an administrative contact address so password resets and error notices land at the consultancy rather than in a void. It creates a navigation menu and pins it to the top of the page.
sudo wp option set blogname "${var.site_name}"
sudo wp option set admin_email sage@limitlessinteractive.com
sudo wp menu create nav
sudo wp menu location assign nav top_nav
And then it writes five pages.
Home. Blog. About. Services. Contact.
Published, in the menu, and completely empty.
I find that ending genuinely moving, and I want to say why.
Everything up to that point is engineering — addresses, credentials, snapshots, package management. And it all terminates in five empty rectangles waiting for somebody to write about brake service.
The scaffolding is the part that can be mechanized. The part that can't is the only part the client actually cares about.
A good factory knows exactly where to stop.
A Category Created for Exactly One Member
Two more things went in today, and both are small, and both are the kind of small that matters later.
The first is an access key pair for logging into these machines directly. The public half is written into the project in full — which is fine, that's what public halves are for. It's a lock, not a key. Publishing it is the entire point.
resource "aws_lightsail_key_pair" "client" {
name = "wp-client"
public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCm66LUSbBoHuGBUYPUPwTYPyN0Z8CXH2+D7Kp//zNle4JCH22/OIFPKtip1eKzGRsMCP/9mDtdc2sHq1llYbGtaRFoIKQ+/Rlb6sooZAusiAIkQLM/mgpRELtMD79JXYExuW2qfni7+HUkkrkpnI3ENZivjnpWtDj7SI92vhnftYtwTFD4pE7bUrOHYFn148VQCJwVUdixg8lwskn2jGHm7lxE40GYdwSdOwnaYNTh1W4jPlI+ieJGsdamBI/U324N6NTNEGMdiX7cEHoxikrlHEh2PqR6qK9uXXzSZaT5MvwtOlY6mwLAdFvaYgnK5YB1ogaG5fH2Fv+UEooA6ceV"
}
The private half is not in the project, because the private half is downloaded to whoever created it, as a file on a disk, with a specific extension.
And so today the ignore list grows a new section, headed simply: keys. One entry underneath it — the pattern that matches those private key files.
# keys
*.pem
A category created for exactly one member. Somebody looked at a new kind of secret arriving on their machine and made a place for it before it had a chance to wander somewhere it shouldn't.
Today It Moved
The second thing is a deletion, over in the mail configuration, and I'm flagging it because I don't fully understand it yet.
The dedicated bounce paths — the separate return-address subdomains that were set up for both companies, with their own delivery and sender policy records — are removed today. All of them.
That could be simplification. Custom return paths are genuinely fussy, they need their own verification, and the benefit is invisible on a good day.
But it's a change to the outbound mail path, made quietly, on a day whose headline was client hosting. And this business has an enforced quarantine rule pointed at its own domains.
I don't want to overstate it. I'm noting it because outbound mail is the one system here that fails without telling anyone, and today it moved.
Four Items
So. Back to the commented-out line.
Everything I've described exists and is finished. The bucket, the scoped credential, the key pair, the client definition, the machine, the address, the naming, the backups, the entire startup script.
And the block that would actually instantiate the automotive shop is disabled, in place, with its arguments fully written out and a note recording what it has to wait for.
Which is, I'd argue, exactly right. Because there's one more file in today's work, and it's six lines of plain documentation, and it's the honest part.
Setup instructions. Four items.
Connect the hosting platform's account, through a browser sign-in. Connect the search tooling, through another browser sign-in. Turn on the expanded navigation menu. Adjust the reading settings.
# Setup Instructions
- Connect Jetpack via oAuth
- Connect Google Site Kit via oAuth
- Enable Mega Menu
- Customize Reader settings
None of those can be scripted, and not because nobody tried. Two of them are interactive authorizations — a human being clicks "allow" in a browser window, on purpose, because the entire security model of that handshake is that a script can't do it. The other two live in a theme's own settings, in a database, behind an interface with no other way in.
So the factory gets a client ninety percent of the way there in ten unattended minutes, and then a person spends fifteen minutes clicking four things.
And that document is what keeps those fifteen minutes from becoming an hour of trying to remember what's missing while a client waits.
The Removal of a Category of Work
Here's what I keep coming back to.
The thing built today isn't a website. It's the removal of a category of work.
Before today, taking on a new client meant a day of setup that was ninety percent identical to the last one, and the ten percent that differed was hidden somewhere inside the ninety.
After today, the identical part is written down once, in a file, where it can be read, corrected, and improved — and improving it improves every client site that will ever be built, including the ones for businesses that don't exist yet.
That's the actual leverage. Not the ten minutes saved. The fact that the setup now has a single place where it can get better.
The machine is built. The theme is licensed and waiting in a bucket. The credential that fetches it can do nothing else. The backups run at six.
Five empty pages are ready for words that haven't been written.
And the last thing standing between all of that and a live client site is two characters at the front of a line. Which is the correct place for a factory to sit, right up until the morning somebody deletes them.