CodeDevlify LogoCodeDevlify
All articles
Engineering

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

11 min read

Updated 4 September 2026

A request travelling from the dashboard through the API to a hosting node, ending at a live padlocked URL.

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.

Three boxes: client portal, backend API, and hosting node agent, connected by a proxy route and a gRPC link.
The browser only ever speaks to its own origin. The backend is the only thing that holds an agent credential.

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.

src/domains/site-manager/use-cases/website/create-website.use-case.tsTypeScript
@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.

proto/website.protoProtobuf
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.

internal/usecase/website_usecase.goGo
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.

StepTypicalWorst realistic caseHow we hide it
Container start1–3s15s on a cold imageImages are pre-pulled on every node at deploy time
Let's Encrypt issuance8–20sSeveral minutes under rate limitsServe on *.codedevlify.site immediately, swap in the custom cert when it lands
Customer DNS propagationMinutesUp to 48 hoursNothing — 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:

Shell
# 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 cat

The 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

  1. Emit progress events from the agent, not just terminal ones. We added them late; they should have been in the first proto.
  2. 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.
  3. 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.

grpcprovisioningqueuesnestjsgoarchitecture
ShareXLinkedIn

Keep reading

Performance

Core Web Vitals Without the Headache

The four front-end decisions that move LCP, INP, and CLS more than any amount of micro-optimisation — with the measurements that tell you whether they worked.

7 min read

New posts, roughly twice a month

Engineering write-ups, performance notes, and what we learned running the platform. No product announcements dressed up as articles.

Unsubscribe in one click. We never share your address.