all posts

Multi-Tenant WordPress Hosting on Firecracker MicroVMs

Ajay Kumar··10 min read

Shared hosting has one architectural assumption at its centre, and it is a strange one when you say it out loud: that the code your customers install is not hostile. Everything else — the pools, the quotas, the open_basedir lines, the polite little chroot — is built on top of that assumption rather than replacing it. It holds up fine right until a tenant installs a plugin from a forum post.

I'm Ajay; I build PandaStack, a Firecracker microVM platform where every sandbox create is a snapshot restore. WordPress hosting is the oldest, most unglamorous version of the exact problem I work on daily — run somebody else's arbitrary code, on your hardware, next to somebody else's arbitrary code, and do it cheaply enough to charge five dollars a month for it. This post is about why the standard PHP isolation stack is not a boundary, what a VM-per-site model changes, and the two objections that actually matter: density and state.

WordPress is a plugin-execution engine wearing a CMS costume

Start with an honest description of the product. WordPress core is a reasonably well-audited piece of PHP with a security team and a release process. That is not what you are hosting. You are hosting core plus a plugin directory containing tens of thousands of packages, plus whatever ZIP your customer uploaded from a marketplace, plus a theme with a functions.php that somebody's cousin wrote in 2017 — all of it running in the same process as core, with the same privileges, on every request.

There is no plugin sandbox. There was never intended to be one; the extensibility model is the product. A plugin is PHP that gets included, and included PHP can open files, make outbound HTTP requests, write to the filesystem, query the database with the site's full credentials, and — if the function list permits it — spawn a process. The auto-update mechanism means that code changes underneath you without a deploy. The file editor in wp-admin means a stolen admin password is a shell.

If you host WordPress for other people, you are operating a multi-tenant remote code execution service. The only open question is what a successful execution reaches.

That reframing is the useful one, because it turns a vague worry into a concrete question you can answer per-architecture. When a plugin runs attacker-controlled PHP — and across a fleet of a few thousand sites, some of them will — what is on the other side of the process boundary? On a classic shared host the answer is: the other tenants' files, if permissions are sloppy; the other tenants' databases, if the MySQL grants are sloppy; the entire host, if any local privilege escalation is available. Three sloppiness conditions, one shared kernel, and a lot of hope.

open_basedir, disable_functions, chroot: polite suggestions

The PHP hardening stack is worth deploying and it is not what people think it is. Each control narrows what a well-behaved plugin can do by accident. None of them changes what a determined one can do on purpose, because none of them is enforced by anything that outranks the code being restricted.

  • open_basedir is a userland check inside PHP's stream layer. It is applied when PHP resolves a path through its own wrappers. It has a long, well-documented history of being walked around via symlinks, race conditions, and functions that forgot to consult it — and structurally it can say nothing about code that leaves PHP's stream layer at all. It is a lint rule with a security reputation.
  • disable_functions is a blacklist on a dynamic language. You are enumerating the exits you currently know about. Every loadable extension, every FFI binding, every mail-transport argument injection and every future language feature is, by definition, not on your list. Blacklists lose to an adversary who only needs one omission.
  • A PHP-FPM pool per tenant is the real one. Separate uid, separate socket, separate chroot — this is genuine Unix isolation and it stops casual cross-tenant file reads dead. What it does not stop is anything that goes around the filesystem: the shared network stack, the shared /proc, the shared MySQL server where the only wall is a grant table, and the shared kernel where one local privilege escalation collapses every pool at once.
  • chroot narrows the path namespace and does nothing else. It is not a security boundary against a process that gains root, it does not affect networking, and it does not reduce the syscall surface by a single entry.

Here is the strongest version of that configuration, annotated with what it does and does not buy. Deploy it. Just do not file it under "isolated".

; /etc/php/8.3/fpm/pool.d/site-4f2a.conf
;
; This is the STRONGEST version of the shared-host model: one pool per
; tenant, its own uid, its own socket, its own chroot. Everything below is
; worth doing. None of it is a boundary.
[site-4f2a]
user  = site-4f2a
group = site-4f2a
listen       = /run/php-fpm/site-4f2a.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

; pm.max_children is the only real resource knob a shared pool gives you.
; The worst case this tenant can cost the box is max_children x memory_limit,
; and that number is charged against RAM every other tenant is also using.
pm                      = ondemand
pm.max_children         = 8
pm.process_idle_timeout = 30s
pm.max_requests         = 500   ; recycle workers; leaks are a plugin feature

; chroot closes the path story for anything that reaches the filesystem
; through a syscall at all. It does not touch /proc, the network stack, the
; MySQL socket, or the kernel. It is a smaller room, not a locked one.
chroot = /srv/sites/site-4f2a
chdir  = /

; open_basedir is enforced in userland, inside PHP's stream layer, on the
; paths PHP itself resolves. It has a long history of being walked around,
; and by construction it says nothing about code that leaves PHP.
php_admin_value[open_basedir]       = /srv/www:/tmp
php_admin_value[upload_tmp_dir]     = /tmp
php_admin_value[sys_temp_dir]       = /tmp
php_admin_value[memory_limit]       = 256M
php_admin_value[max_execution_time] = 30
php_admin_flag[allow_url_fopen]     = off
php_admin_flag[allow_url_include]   = off
php_admin_flag[expose_php]          = off

; And the blacklist. Be clear about what this is: an enumeration of the ways
; you currently know of to leave PHP. Every extension the tenant can load,
; every FFI binding, and every future addition to the language is not on it.
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,dl,putenv,mail

The web-server half deserves the same scrutiny, because the single most reliable way to turn a WordPress upload into code execution is a PATH_INFO misconfiguration that nobody has looked at since it was pasted from a tutorial.

# /etc/nginx/sites-enabled/site.conf
#
# This config lives INSIDE the site's own microVM, so there is exactly one
# tenant behind it. Most of what makes a shared-host nginx config dangerous
# is the part where it is not.
server {
    listen 80 default_server;
    root  /srv/www/wordpress;
    index index.php;

    client_max_body_size 64m;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        # The classic WordPress arbitrary-execution footgun. Without this
        # try_files guard, a request for /uploads/cat.jpg/x.php gets split by
        # PATH_INFO and handed to PHP-FPM, which cheerfully executes the JPEG
        # that a vulnerable upload handler accepted. On a shared pool that is
        # one tenant's upload becoming everybody's incident.
        try_files $fastcgi_script_name =404;

        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass  unix:/run/php-fpm/wordpress.sock;
        fastcgi_index index.php;
        include       fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_read_timeout 30s;
    }

    # Uploads are data, not code. Belt and braces for the rule above.
    location ~* /wp-content/uploads/.*\.(php|phar|phtml)$ { deny all; }

    # wp-cron.php is a normal HTTP request, which means any visitor -- or any
    # crawler -- can trigger every scheduled job on the site, including the
    # plugin that decided a full reindex was a good idea. Turn it off in
    # wp-config (define('DISABLE_WP_CRON', true)) and drive it from a real
    # scheduler instead.
    location = /wp-cron.php { deny all; }
}
Every control above is enforced by the same kernel that the attacker's code is running on, using a configuration that the attacker's process could influence if it ever gets to root. The security question is not "how many restrictions did I apply?" It is "what is the smallest thing that has to fail for all of them to stop mattering?" On a shared PHP host, that thing is a single kernel bug.

The container middle ground, and where it stops

Container-per-site is a real improvement and most competent managed-WordPress platforms land here. Now each tenant gets a mount namespace, a PID namespace, a network namespace and a cgroup. The casual cross-tenant read is not merely discouraged, it is unrepresentable — the neighbour's filesystem is not in this container's mount table. Resource limits stop being an honour system. This is a genuine step up from pools, and if you are running shared PHP-FPM today, moving to containers is the highest-leverage change available to you.

The ceiling is the shared kernel, and the shape of the risk is worth stating precisely rather than hand-waving about "container escapes". A container is a set of restrictions the kernel agrees to apply to a process it is otherwise running normally. Every syscall that process makes is handled by the same kernel code that serves every other tenant on the box. Unless you have written a seccomp profile — and nobody writes a seccomp profile for WordPress, because WordPress is PHP plus ImageMagick plus whatever the plugin shells out to — that process reaches most of a very large syscall surface. One exploitable bug behind that surface, and the namespace boundary is a formality.

A container is a polite suggestion to the kernel. The kernel is the thing you were trying to protect.

The practical consequence for a hosting business is not that containers are bad. It is that your worst-case blast radius is "every site on the machine", and no amount of configuration reduces it, because the thing that would have to hold is not something you configure. You can only make that number smaller by not sharing the kernel.

The three models, side by side

  • Isolation boundary — Shared PHP-FPM pool: Unix file permissions plus userland PHP checks, all enforced by one kernel. Container per site: kernel namespaces and cgroups, enforced by one kernel. MicroVM per site: a hardware virtualization boundary, enforced by the CPU, with a separate guest kernel per tenant.
  • What a compromised plugin reaches — Shared pool: its own chroot, the shared network, the shared database server, and everything else on the box if it finds a privilege escalation. Container: its own namespace, plus the shared kernel's full syscall surface. MicroVM: its own kernel and its own disk image; escaping means a hypervisor bug, not a kernel bug.
  • Attack surface presented to untrusted code — Shared pool: the full Linux syscall ABI, hundreds of entry points. Container: the same ABI, minus whatever seccomp profile you wrote, which is usually none. MicroVM: a small virtio device model behind a jailer and a seccomp filter written by the VMM authors, not by you.
  • Memory blast radius — Shared pool: memory_limit times max_children, competing for one pool of host RAM; the OOM killer picks a victim and it may not be the offender. Container: a cgroup limit, honestly enforced, with kernel memory still shared. MicroVM: a hard wall — the guest OOM-kills inside its own kernel and no neighbour observes anything.
  • Network — Shared pool: one host network stack, one route table, one set of iptables rules that must encode every tenant's policy. Container: a veth pair per site, policy still in the host's tables. MicroVM: a dedicated network namespace, tap device and /30 subnet per sandbox — on our agents, 16,384 pre-allocated slots per host, so per-tenant network policy is per-namespace rather than a rule-ordering puzzle.
  • Idle cost — Shared pool: near zero; an idle site is a config file and some disk. Container: low but non-zero; the process tree stays resident. MicroVM: the objection everyone raises, and the one that scale-to-zero plus snapshot restore is designed to answer — an idle site is a snapshot in object storage, not resident RAM.
  • Cold start after idle — Shared pool: none, nothing was stopped. Container: hundreds of milliseconds to seconds, depending on image and init. MicroVM: a snapshot restore, which on our fleet is 179ms at p50 and around 203ms at p99 for the create path, with roughly 49ms of that being the snapshot load itself.
  • Operational unit — Shared pool: a config directory per tenant; a PHP upgrade is one apt command and a fleet-wide prayer. Container: an image per tenant or a shared image; upgrade is a rebuild and rollout. MicroVM: a baked template plus a per-site snapshot; a PHP security update is a template rebake and a redeploy, which is more work and much more auditable.

The density objection, answered properly

"A VM per site" has historically meant "a gigabyte of resident RAM per site", and at that exchange rate the model dies on contact with the price sheet. Nobody is running a hundred thousand VPSes to host a hundred thousand brochure sites. So the objection is correct as stated, and the answer is not that microVMs are small — although they are. The answer is that the VM should not be running.

Think about what a real fleet of WordPress sites is doing at any given instant. A small minority are serving traffic. The rest are a restaurant menu, a wedding, a consultancy's four pages, a blog last updated in 2019 — each of them receiving a handful of human visits a day and a great deal of bot traffic. Keeping a process tree resident for a site that gets forty requests a day is a rounding error on a container host and ruinous on a VM host. Unless you stop paying for it between requests.

That is what snapshot restore buys, and it is the reason the whole architecture works. A sleeping site is not a paused VM holding its RAM. It is a memory image and a disk image in object storage. A request arrives at the router, the router notices the site has no live sandbox, and a restore happens. On our fleet a create-by-restore is 179ms at p50 and about 203ms at p99; the snapshot load step inside that is roughly 49ms and the rest is network setup, disk clone and the readiness probe. The first ever boot of a template — before there is a snapshot to restore — takes around 3 seconds, and it happens once.

So the honest performance story is: an awake site is as fast as any other PHP host, and a sleeping site's first visitor pays roughly a fifth of a second before PHP even starts. Put a CDN in front, which you were going to do anyway for a WordPress fleet, and the overwhelming majority of requests never reach the origin at all. The ones that do are page loads where a couple of hundred milliseconds sits inside the noise of WordPress's own time-to-first-byte.

There is an unglamorous prerequisite here that took us a production incident to learn: automated traffic must not wake a sleeping site. Uptime monitors, security scanners and the general background radiation of the internet will hit a WordPress URL constantly. If every one of those wakes the VM, your scale-to-zero fleet is a scale-to-one fleet with extra steps and a mysteriously large bill. You need a traffic classifier that decides which requests are allowed to cause a wake and which get a cheap static response while the site stays asleep.

State is the hard part, not isolation

Isolation is the part of this that is genuinely solved by the architecture. State is the part where you have to make real decisions, because a disposable VM and a WordPress installation want opposite things. Split the site's state into three piles and treat each one differently.

  1. Code — core, plugins, themes. Reproducible from a manifest, and it should be. This belongs in the template or in a git-driven deploy, not in a snapshot you are afraid to rebuild. If you cannot recreate a tenant's code tree from a declarative source, you do not have a hosting platform, you have a pet.
  2. Uploads — wp-content/uploads. Irreplaceable, unbounded, and written by the application at runtime. This is the pile that fights the model.
  3. The database — every post, comment, option and serialized settings blob. Irreplaceable, and the one thing a WordPress site genuinely cannot be reconstructed without.

For uploads there are two workable answers and one trap. The trap is leaving them on the ephemeral rootfs, where copy-on-write makes them free to create and a rebuild makes them free to lose. The first real answer is a durable volume: an ext4 image per tenant, attached to the guest as a block device, living on the host rather than in the VM's disposable disk. The second — and the one I would actually reach for on a WordPress fleet — is media offload to object storage, which is a solved problem in the WordPress ecosystem and has the enormous side benefit of making the web VM completely disposable.

That preference is not aesthetic. It comes from a hard constraint in the restore path that is worth understanding before you design around volumes. Firecracker's snapshot restore reproduces the device topology baked into the snapshot state, and you can only patch drive IDs that already exist in it. Our agent therefore refuses a volume attachment on a restore rather than silently dropping it — a sandbox whose caller believes it has a durable volume but does not is data loss with a delay fuse. The practical consequence is that a volume-attached create cold boots, on the order of 3 seconds, instead of taking the sub-200ms restore path.

# Uploads and the database are the two things you cannot rebuild from git.
# A durable volume is one ext4 image per tenant, attached to the guest as
# /dev/vdb and owned by that tenant's workspace -- never shared across them.
curl -sS -X POST "$PANDASTACK_API/v1/volumes" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name":"uploads-4f2a","size_mb":8192}'

# Attach it at create time -- and read the constraint before you copy this.
# Firecracker's restore reproduces the device topology baked into vm.state,
# and you can only patch drive IDs that already exist there. So our agent
# refuses volumes on a restore rather than silently dropping them:
#
#   "volumes cannot be attached when restoring from a snapshot;
#    create a fresh sandbox with volumes instead"
#
# Which means a volume-attached create COLD BOOTS -- roughly 3 seconds --
# instead of taking the ~179ms restore path. That single sentence is the
# entire argument for keeping the web tier stateless and pushing uploads
# to object storage.
curl -sS -X POST "$PANDASTACK_API/v1/sandboxes" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "template":   "wordpress",
        "persistent": true,
        "metadata":   {"tenant": "4f2a"},
        "volumes":    [{"name": "uploads-4f2a"}]
      }'

# The database gets its own VM and its own volume, provisioned once per
# tenant. It takes 30-90s because it blocks until the server is genuinely
# accepting connections, which is the right trade for a thing you create
# once and connect to for years.
curl -sS -X POST "$PANDASTACK_API/v1/databases" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"label":"tenant-4f2a","size":"1g"}'

The database is easier, because putting it in its own VM per tenant is straightforwardly better than the shared-server alternative and costs you nothing architecturally. A shared MySQL server means every tenant's data is one grant-table mistake, one SQL injection in one plugin, or one privilege escalation away from every other tenant's data — and it means one tenant's unindexed query is everybody's slow site. A database VM per tenant makes the blast radius exactly one customer. Ours provision in 30 to 90 seconds, because the call blocks until the server is genuinely accepting connections; that is the right trade for something you create once per tenant and then connect to for years.

WordPress is MySQL-native, so a Postgres-backed managed database means a compatibility layer and a bad afternoon. Run MySQL or MariaDB from your own baked template with a durable volume attached — the shape is identical to the managed-Postgres model described here, you are just supplying the template. The design point that matters is one database VM per tenant with its own disk, not which engine is inside it.

One runaway wp-cron should not be your problem

The other half of multi-tenancy is the boring half that generates all the support tickets. A plugin schedules an hourly job that walks every post. A tenant imports a 40,000-product catalogue. Somebody's backup plugin decides to tar the entire site into wp-content, inside the request. On a shared host, all of these are load-average events that every other tenant on the box experiences as their site being slow, and your only lever is pm.max_children.

Memory is where the VM model wins most decisively, and it is the least-discussed benefit. A guest that exhausts its RAM OOM-kills a process inside its own kernel. The host never sees memory pressure, the host OOM killer never runs, and no neighbour observes anything at all. Compare that with a shared pool, where the kernel's OOM killer picks a victim by heuristic and the victim is frequently not the offender — a failure mode that produces support tickets from the tenant who did nothing wrong.

CPU is a different shape, and it should be. We put each Firecracker process in its own cgroup-v2 child with cpu.weight proportional to its vCPU entitlement — 100 per vCPU, so an 8-vCPU sandbox carries a weight of 800. Weights only bind under contention, which gives you the property you actually want on a WordPress fleet: on an idle host, any site can burst to the physical cores and render its page fast; under contention, cores divide in weight proportion instead of by thread-count lottery. A tenant paying for more gets more, but nobody is throttled while the machine is quiet.

One constraint to design around: Firecracker cannot change a guest's vCPU count or RAM at snapshot restore, so the memory a site gets is a property of the template that was baked, not a per-site field you can set at create time. If you want a 512 MiB starter tier and a 2 GiB business tier, that is two baked templates, not one template and a parameter. Our own base app template is 4 GiB with 8 vCPU of burst for exactly this reason — the number was chosen at bake time and every restore inherits it.

The operational shape

Put together, the platform is smaller than it sounds, because the expensive parts happen once rather than per-tenant.

  1. Bake one template. Ubuntu, nginx, PHP-FPM, the extension set every WordPress needs, wp-cli, and an init that comes up clean. Boot it once, snapshot it, publish the snapshot. This is the only slow build in the system.
  2. Provision per tenant. Restore the template, write the per-site wp-config, run wp core install, point it at that tenant's database VM. Minutes, once, at signup.
  3. Route by host. A router maps the incoming Host header to a sandbox. If the sandbox is live, proxy. If the site is asleep, restore it and then proxy — holding the request rather than bouncing the visitor to a refresh page, if the wake fits inside your hold budget.
  4. Sleep aggressively. Idle sites hibernate to object storage. This is the line item that makes the economics work, so the idle timeout should be short and the wake path should be the fast one.
  5. Run cron out of band. DISABLE_WP_CRON in wp-config, and a scheduler that runs wp cron event run --due-now against each site on its own cadence. This also means a sleeping site's scheduled jobs are a deliberate decision rather than a side effect of whichever bot happened to crawl it.
  6. Upgrade by rebake. A PHP or nginx security update is a new template, a rebake, and a rolling redeploy of every site. More work than apt upgrade on a shared box, and vastly easier to audit and roll back.
import os
from pandastack import Sandbox

# One microVM per tenant site, restored from a template that already contains
# nginx, php-fpm, the extension set and wp-cli. The template is baked once;
# every site created after that is a snapshot restore, not an install.

def provision_site(tenant: str, domain: str, db_url: str) -> str:
    sbx = Sandbox.create(
        template="wordpress",   # your baked template
        persistent=True,        # exempt from the idle reaper; it sleeps instead
        metadata={"tenant": tenant, "domain": domain, "kind": "wp-site"},
    )

    # wp-config.php is the one per-site file that cannot come from the
    # template: fresh salts, and DB credentials pointing at THIS tenant's
    # database VM rather than a row in a shared server's grant table.
    sbx.filesystem.write(
        "/srv/www/wordpress/wp-config.php",
        render_wp_config(                       # your own renderer
            db_url=db_url,
            salts=os.urandom(64).hex(),
            extra=(
                "define('DISABLE_WP_CRON', true);\n"
                "define('DISALLOW_FILE_EDIT', true);\n"
                "define('FS_METHOD', 'direct');\n"
            ),
        ),
    )

    # wp-cli does the install. This is the slow step, and it happens once per
    # tenant -- not once per request, and not once per wake.
    r = sbx.exec(
        "cd /srv/www/wordpress && wp core install"
        f" --url=https://{domain} --title={tenant}"
        f" --admin_user=owner --admin_email=owner@{domain}"
        " --skip-email --allow-root",
        timeout_seconds=180,
    )
    if r.exit_code != 0:
        sbx.kill()              # kill() is the teardown method
        raise RuntimeError(f"install failed: {r.stderr}")

    sbx.exec("systemctl restart php8.3-fpm nginx", timeout_seconds=30)
    return sbx.id


def run_due_cron(sandbox_id: str) -> str:
    # The scheduler-driven replacement for wp-cron.php. Runs outside the
    # request path, so a visitor never pays for a plugin's scheduled job and
    # a bot can never trigger one.
    sbx = Sandbox.get(sandbox_id)
    return sbx.exec(
        "cd /srv/www/wordpress && wp cron event run --due-now --allow-root",
        timeout_seconds=300,
    ).stdout


def park_idle_site(sandbox_id: str) -> None:
    # The density answer. An idle site is not a running VM; it is a snapshot
    # in object storage that a request will restore.
    Sandbox.get(sandbox_id).hibernate()

When not to do this

I would rather name the cases where this is the wrong architecture than pretend it is universal. If every one of your tenants is a high-traffic site that never idles, VM-per-site is just VPS hosting with better tooling, and the economics are VPS economics — fine, but do not expect the scale-to-zero magic, because nothing is ever at zero. If your margin depends on packing thousands of sites onto one small box at near-zero marginal cost and your customers install nothing but a theme, the shared model's risk may genuinely be priced correctly for you.

And be clear-eyed about the operational cost. A baked template is a build artifact with a lifecycle: you need a pipeline, a version, a rollout, and a rollback. A fleet of per-tenant database VMs needs backups, and backups need restore drills, and restore drills are the thing everyone skips. None of this is exotic, but it is more machinery than a directory of pool configs.

What you get in exchange is a straight answer to the question that has haunted shared hosting since it was invented. When a tenant's plugin runs hostile code — not if — what does it reach? On a shared pool: a userland path check and a grant table. On a container: a kernel it shares with every customer you have. On a microVM: its own kernel, its own disk, its own network namespace, and a hypervisor boundary between it and everybody else. That is not a configuration difference. It is a different answer to a different question, and it is the only one you can put in a security questionnaire without qualifying it.

Frequently asked questions

Is open_basedir a real security boundary for multi-tenant PHP?

No, and PHP's own documentation has never claimed it is one. open_basedir is enforced in userland by PHP's stream layer, on paths that PHP itself resolves through its own wrappers. That gives it two structural problems. First, it only sees what goes through those wrappers, so anything that reaches the filesystem another way is outside its remit entirely. Second, it has a long history of being circumvented through symlinks, race conditions, and individual functions that failed to consult it — the pattern of a control that has to be re-implemented at every call site rather than enforced once at a chokepoint. Treat it the way you would treat a lint rule: keep it on, because it catches sloppy code and accidental path traversal, and never let its presence be the reason you believe two tenants are isolated. The same reasoning applies to disable_functions, which is a blacklist of the exits you currently know about, on a language with dynamic dispatch, loadable extensions and FFI.

Isn't a microVM per WordPress site far too expensive to be viable?

It is if the VMs stay running, which is why the model depends on them not staying running. The economics of a WordPress fleet are dominated by the fact that most sites are idle most of the time — a few human visits a day, plus bot traffic. If an idle site is a resident VM holding its RAM, the cost is indefensible. If an idle site is a memory image and a disk image sitting in object storage, its steady-state compute cost is essentially zero and you pay only when someone actually visits. The mechanism that makes this practical is snapshot restore: on our fleet a create-by-restore is 179ms at p50 and around 203ms at p99, with roughly 49ms of that being the snapshot load itself. So the first visitor to a sleeping site waits about a fifth of a second before PHP starts, and behind a CDN most requests never reach the origin at all. The prerequisite people miss is a traffic classifier: automated traffic — uptime monitors, scanners, crawlers — must be served cheaply without waking the site, or your scale-to-zero fleet quietly becomes a scale-to-one fleet.

Where do wp-content/uploads and the database live if the VM is disposable?

Split the site's state into three piles and treat each differently. Code — core, plugins, themes — should be reproducible from a manifest or a git-driven deploy, so it belongs in the template rather than in a snapshot you are afraid to rebuild. Uploads are the awkward pile: leaving them on the ephemeral rootfs means a rebuild loses them, so you either attach a durable volume (an ext4 image per tenant, on the host rather than in the VM's disposable disk) or offload media to object storage. On a WordPress fleet, media offload is usually the better answer, because it keeps the web VM genuinely disposable — and because there is a real constraint the other way: Firecracker's restore reproduces the device topology baked into the snapshot, so a volume-attached create has to cold boot (on the order of 3 seconds) rather than taking the sub-200ms restore path. The database goes in its own VM per tenant with its own durable disk, which makes the blast radius of any database-level failure exactly one customer.

Can a runaway plugin or wp-cron job still take down the host?

Not in the way it does on a shared PHP host, and memory is the clearest case. A guest that exhausts its RAM OOM-kills a process inside its own kernel; the host never sees memory pressure and no neighbour observes anything. On a shared pool the host OOM killer picks a victim by heuristic, and the victim is frequently a tenant who did nothing wrong — which is how one site's badly written import job becomes three support tickets from other customers. CPU is deliberately softer. We place each VM in its own cgroup-v2 child with cpu.weight proportional to its vCPU entitlement, and weights only bind under contention. On a quiet host any site can burst to the physical cores; under contention, cores divide in weight proportion rather than by thread-count lottery. Separately, the specific wp-cron problem is best fixed at the source: set DISABLE_WP_CRON in wp-config and drive scheduled jobs from a real scheduler, so no visitor and no crawler can trigger a plugin's hourly reindex by loading the homepage.

Do I need a separate template for each memory tier?

Yes, and this surprises people. Firecracker cannot change a guest's vCPU count or memory size at snapshot restore — the values are frozen into the snapshot when it is baked, and the restore reproduces them. So the RAM a site gets is a property of the template it was created from, not a parameter you pass at create time. If you want a 512 MiB starter tier and a 2 GiB business tier, that is two baked templates in your catalogue, each with its own bake and its own upgrade path. Size them for the worst thing that happens inside them rather than the steady state: our own base app template is 4 GiB precisely because build steps spike far above what the running application needs. It is worth planning the tier list before the first bake, because adding a tier later means a new template rather than a config change.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.