Anatomy of a 30-Second Site Provision
What actually happens between clicking “Create site” and a live HTTPS URL — the gRPC call, the container, the DNS wait, and the four places we had to stop blocking the request.
Platform Engineering
Infrastructure & reliability
Updated 4 September 2026
Creating a website on CodeDevlify looks like one button. Behind it are two services, a queue, a container runtime, a certificate authority, and a DNS propagation window that nobody controls. This post walks the whole path — and shows where we moved work off the request so the button could stay honest about being fast.
The shape of the system
There are three moving parts. The backend is a NestJS modular monolith that owns customer data and billing. The agent is a small Go binary running on every hosting node, exposing gRPC to the backend and nothing at all to the internet. The client is a Next.js portal that never talks to the backend directly — every browser request goes through a same-origin proxy route.
That last constraint is the one that shapes everything else: the agent is the only component allowed to touch a hosting node's disk, so provisioning is a conversation, not a script.
Step one: the request that returns immediately
The controller does almost nothing. It validates the DTO, hands off to a use-case, and the use-case writes one row and enqueues one job. Total time in the request: single-digit milliseconds plus one insert.
@Injectable()export class CreateWebsiteUseCase { constructor( @Inject('IWebsiteRepository') private readonly websites: IWebsiteRepository, @InjectQueue('website-provisioning') private readonly queue: Queue, private readonly events: EventEmitter2, ) {} async execute(input: CreateWebsiteInput): Promise<Website> { const website = Website.create({ ...input, status: 'provisioning' }); await this.websites.save(website); // Everything below this line is slow, so none of it happens here. await this.queue.add('provision', { websiteId: website.id }, { attempts: 3, backoff: { type: 'exponential', delay: 5_000 }, }); this.events.emit('website.created', new WebsiteCreatedEvent(website)); return website; }}Step two: the gRPC call to the node
The queue consumer picks a hosting node, opens a gRPC channel, and asks the agent to create the site. The contract is a single proto file kept in both repositories — one of the few places where the two codebases are genuinely coupled.
service WebsiteService { rpc CreateWebsite (CreateWebsiteRequest) returns (CreateWebsiteResponse); rpc SuspendWebsite (SuspendWebsiteRequest) returns (StatusResponse); rpc DeleteWebsite (DeleteWebsiteRequest) returns (StatusResponse);}On the Go side the handler is deliberately boring. It creates the document root, writes a vhost, starts the container, and reports back. No retries, no cleverness — the backend owns retry policy, because the backend is the one that knows how many times it has already asked.
func (u *WebsiteUseCase) Create(ctx context.Context, req *pb.CreateWebsiteRequest) (*pb.CreateWebsiteResponse, error) { root := filepath.Join(u.baseDir, req.GetSiteId()) if err := u.fs.EnsureDir(root, 0o755); err != nil { return nil, status.Errorf(codes.Internal, "create root: %v", err) } // Idempotent on purpose: the backend may retry this exact call after a // timeout, and a half-created site must converge, not duplicate. if err := u.containers.EnsureRunning(ctx, req.GetSiteId(), req.GetRuntime()); err != nil { return nil, status.Errorf(codes.Internal, "start container: %v", err) } return &pb.CreateWebsiteResponse{DocumentRoot: root, Status: "running"}, nil}Step three: the parts you cannot speed up
Two things in this flow are outside our control, and pretending otherwise is how you end up with a progress bar that lies.
| Step | Typical | Worst realistic case | How we hide it |
|---|---|---|---|
| Container start | 1–3s | 15s on a cold image | Images are pre-pulled on every node at deploy time |
| Let's Encrypt issuance | 8–20s | Several minutes under rate limits | Serve on *.codedevlify.site immediately, swap in the custom cert when it lands |
| Customer DNS propagation | Minutes | Up to 48 hours | Nothing — we show the real state and poll the record |
The third row is the honest one. We can't shrink a TTL somebody else set, so the dashboard shows pending DNS with the exact record to add, and a checker that re-runs every thirty seconds until it resolves.
A fast product is not one where everything is fast. It's one where nothing you're waiting on is a mystery.
— Internal design review, March 2026
Watching it happen
Because every stage emits an event, the whole provision is observable from a single log stream while you're debugging a node:
# Follow one site through the whole pipelinedocker compose logs -f backend | grep --line-buffered "site_id=ws_8f3a" # ...and what the node itself thought was happeningjournalctl -u codedevlify-agent -f --output catThe two streams should interleave cleanly. When they don't — when the backend logs a retry the agent never saw — the answer is almost always a deadline that's shorter than a cold container start.
What we'd do differently
- Emit progress events from the agent, not just terminal ones. We added them late; they should have been in the first proto.
- Pick the node before enqueuing. Choosing inside the consumer made scheduling harder to reason about, because a job could outlive the node it was destined for.
- Treat certificate issuance as its own domain from day one. It grew inside site-manager and had to be pulled out later.
None of these were expensive to fix. They were expensive to notice, which is the usual shape of infrastructure debt.