From Wix to Self-Hosting my Blog
Moving BA Soapbox to Grav on Oracle Cloud
Important
Disclaimer: The writing and musing of the author do not necessarily reflect the views of his employer.
If you have ever looked at a hosting invoice, or at the promotional banner a free plan pastes across the top of your own pages, and thought there ought to be a version of this you actually control — this post is for you.
There has been a fair amount of moving around here lately. I recently made the much bigger move from the UK to the US, a change I have written about elsewhere on BA Soapbox. The blog has now made a move of its own.
BA Soapbox has moved. After several years on Wix, the blog now runs on a self-hosted Grav instance on Oracle Cloud Infrastructure (OCI) and its Always Free tier, provisioned end to end with Terraform.
In this post I will go over why I moved BA Soapbox, what the stack looks like, and — more usefully — the handful of things that did not work the first time. The tooling itself was rarely the problem. It was the layers underneath it that took the real time.
Why leave Wix
Wix is a genuinely good product for what it is built for, and I would still recommend it to somebody who wants a site up this afternoon and never wants to think about a server again. But the moment you want to own the infrastructure, you hit its ceiling quickly. What I wanted was:
- A blog that lives in the same Terraform codebase as the rest of my cloud estate, reviewed and versioned the same way
- No recurring hosting fee — most managed platforms run somewhere between $9 and $25 a month, and this runs inside Oracle's Always Free Arm allowance
- Full control of the stack, down to the reverse proxy configuration
- Somewhere I could put a second, unrelated project without paying twice
That last one turned out to matter more than I expected. It is why the blog ended up on Grav, a flat-file Content Management System (CMS) with no database at all — once a second project had to share the instance, a database server dedicated to a low-traffic blog stopped earning its keep.
The stack
- Compute: one Always Free
VM.Standard.A1.Flexinstance — 2 OCPU (Oracle's unit of compute, one physical core each) and 12GB of memory, running Ubuntu 22.04 on Arm - Storage: a dedicated 50GB block volume for site content, kept separate from the boot volume so the content survives an instance rebuild — and pinned to the "Lower Cost" performance tier rather than OCI's paid default, for reasons I get into further down
- Networking: one Virtual Cloud Network (VCN) with a single public subnet and an Internet Gateway (IG), guarded by two independent OCI firewall layers — the subnet's Security List (SL) and the instance's Network Security Group (NSG) — plus the Uncomplicated Firewall (
ufw) on the host itself - DNS: an OCI-managed Domain Name System (DNS) zone holding the apex A record, the
wwwCanonical Name (CNAME) record and the DomainKeys Identified Mail (DKIM) CNAME — everything except delegating the nameservers, which has to happen at the registrar - Application: Grav, a flat-file Content Management System (CMS) — no database at all, content lives on disk as Markdown and YAML — in a single Docker container
- Reverse proxy: Nginx on the host, terminating Transport Layer Security (TLS) and proxying to the container on localhost
- Certificates: Let's Encrypt via Certbot, renewing automatically on a systemd timer
- Outbound mail: OCI Email Delivery, with the Simple Mail Transfer Protocol (SMTP) credentials held in OCI Vault
- Infrastructure as code: Terraform, with the whole host in its own module so it is one reviewable, destroyable unit
One thing worth mentioning on shape sizing: I had originally planned around the commonly quoted 4 OCPU / 24GB Always Free Arm allowance. My tenancy's actual limit, confirmed on the Console's own Limits page, is half that. Check yours before you design around a number you read in a blog post — including this one.
If you would rather read the code than the prose, the whole stack is on GitHub as a reference implementation: BASoapbox/wix-to-self-hosting-on-oci-tf. It is a sanitized, single-purpose version of what actually runs this site — same modules, placeholder values — so you can clone it, fill in a terraform.tfvars, and get the same result.
The architecture
Written out as a list, those pieces sound more separate than they are. This is how they actually fit together:

A visitor's request follows the solid line. The browser resolves the apex A record from the OCI DNS zone, connects over HTTPS to the instance's public IP, and then has to get past the IG and both OCI firewall layers — the SL and the NSG — before it reaches the host at all. On the host, ufw is a third gate, Nginx terminates TLS and redirects port 80 to 443, and the Grav container listens only on 127.0.0.1:2380 — it is never directly exposed to the internet. Site content lives on the block volume, not on the boot disk.
Everything inside the dashed compartment boundary is created by Terraform. Two things deliberately are not. The nameserver delegation at the registrar, top right, is the one step that has to be done by hand. And the Vault, dotted at the bottom right, is an existing resource this stack only writes into — it stores the generated SMTP credential there rather than creating a Vault of its own.
In Terraform terms that is five modules: compartment, network, grav_host, dns and email_delivery. The host module owning its own NSG and block volume alongside the instance is the part I would call out as a deliberate choice rather than an obvious one — it means tearing the blog down is a clean destroy of a single module, instead of a hunt for orphaned resources scattered across a generic compute map.
What went smoothly, and what I still did by hand
Getting from nothing to a running, correctly-networked, correctly-firewalled Ubuntu box was genuinely fast. A single terraform apply provisioned the instance, its NSG, the block volume, the VCN and subnet, and the DNS zone with its records. A cloud-init script handled first-boot setup — Docker, Nginx, Certbot, ufw — with no SSH required for any of it.
I want to be careful not to oversell that, though, because "Terraform did it all" is the version of this story I would have written if I had not gone back and counted. Terraform got me an empty server. Everything that turned it into a blog I did by hand over SSH, and my own runbook for it is twelve phases long:
- Create the content directories and copy the Compose file up, then bring the container up
- Copy the Nginx site config into place, symlink it, test and reload
- Run Certbot, once DNS had actually propagated
- Retrieve the generated SMTP credential out of OCI Vault and put it where the application could use it
- Fix file ownership so the container could write to its own content — the failure this caused is a whole section below
- Delegate the nameservers at the registrar, the one step Terraform genuinely cannot do
None of that is hard, and all of it is scriptable — most of it should have been scripted from the start rather than performed once and written down. The honest reason it was not is that each step felt like a one-off at the time, and a one-off you have done twice is a script you have not written yet. The certificate step is the one I did automate, and unsurprisingly it is the one I now re-run without thinking. The reference stack's README documents this ground; what it does not yet do is perform it for you.
What did not
Several things turned "this should be quick" into a real debugging session. Each one is written up here because each one taught me something I would otherwise have got wrong again.
The block volume that would not mount. My bootstrap script assumed the data volume would appear at a predictable path — /dev/oracleoci/oraclevdb — which is a convention from Oracle Linux's oci-utils package. Except I was running Ubuntu, which does not ship that package, and where the same disk simply shows up as a plain /dev/sdb. The mount failed silently on first boot and I did not notice until I went looking for content that was not there. The fix was to stop hardcoding the path and detect the data disk at boot instead — "whichever disk is not the root disk" is unambiguous when you know exactly one data volume is attached.
Two firewalls, not one. With the application running, the site still was not reachable on ports 80 and 443, despite an NSG that explicitly allowed both. It took me longer than I would like to admit to remember that OCI enforces both the subnet's security list and the instance's NSG on inbound traffic. My NSG was correct. The subnet's shared security list, which predated this project and had never needed a web rule, had no matching entry. Both layers have to agree or nothing gets through.
The network fault that pointed nowhere obvious. This was the strangest one. After a routine re-apply, the instance became completely unreachable — not just from my home network, but from a second machine, from mobile data on a different carrier, from a multi-region uptime checker, and from OCI's own Cloud Shell running in the same region. Every layer I could inspect was correct: NSG, security list, route table, internet gateway.
That last test is the one that mattered. A failure from inside Oracle's own network rules out your router, your Internet Service Provider (ISP) and your laptop in a single step. A full stop and start of the instance — not an operating system reboot — cleared it, even though the public IP address did not change. My best guess is a stale routing or virtual network interface placement state that only a real stop/start resets. If you ever find yourself blaming your home network for something like this, reach for the Cloud Shell test early. It is thirty seconds and it collapses the search space enormously.
A container running as a different user than the one copying files in. This one only appeared once I started moving migrated content onto the server. Grav's container serves everything as www-data, but content copied in from outside — an scp from my laptop, a migration script — lands owned by whoever ran the copy. The mismatch does not announce itself as a permissions error. It presents as a flat "Couldn't save" on every edit in the admin interface, with the real cause buried in a container log rather than anywhere the user interface will show you.
The fix was a one-line chown back to www-data. The lesson is broader than Grav, and it is the one I would most want you to take away: if you are bind-mounting content into any container that runs as a non-root user, the process actually serving your files needs to own them — not whichever user happened to put them there. Copying content in as that user from the start avoids the whole class of problem.
Always Free is not automatically free. A charge of $0.91 appeared on an invoice for infrastructure that was supposed to sit entirely within the free tier. The Cost Analysis view suggested Object Storage; the actual invoice line items said otherwise — and there were three of them, not one:
Usage Billing - Block Volume Performance - Performance Units Per Gigabyte Per Month
Usage Billing - Block Volume Storage - Gigabyte Storage Capacity Per Month
Usage Billing - DNS - 1,000,000 Queries
The bulk of it was the first line. OCI block volumes default to the "Balanced" performance tier — 10 volume performance units per gigabyte — which is a paid tier, billed separately from storage capacity. A low-traffic blog has no input/output requirement that justifies it. Setting the tier explicitly to "Lower Cost" in Terraform fixed it, and applies in place with no downtime. It is worth also watching total block storage across the whole tenancy against the 200GB free cap, since Always Free eligibility on a given volume can quietly fall off once the total tips over.
The third line is different in kind, and worth being straight about. OCI's managed DNS is not part of Always Free, and it never will be. It is billed by query — $0.02 for a full million in that invoice, which is to say almost nothing at this size, but it is a real recurring charge that does not go away. The first two lines were a misconfiguration I corrected; this one is simply the price of the service. I am keeping it, because an OCI zone stays in the same Terraform codebase as everything else and that is the entire point of the exercise. But "runs on the free tier" was never quite true, and you should hear that here rather than find it on your own invoice — budget a couple of cents a month, and read the invoice line items rather than the Cost Analysis summary, which was actively misleading.
The volume I wrote about was not holding my content. Everything above describes a dedicated block volume for site content, kept separate from the boot volume so the site survives a rebuild. That was true of my Terraform. It was not true of my server.
An earlier version of the codebase mounted the volume at one path and a later version renamed it — but that rename lives in the bootstrap script, which runs exactly once, on first boot, and the resource deliberately ignores changes to it so that editing the script does not force a rebuild. terraform plan therefore reported no changes, correctly, forever, while the machine carried on with the original layout. For two weeks the volume sat at the old path still running the Ghost install I had migrated off months earlier, while the blog and a second project's live Postgres sat on the boot disk — the disposable one. Nothing misbehaved, because a site serves identically from either disk. I would have found out at exactly one moment: the first time I rebuilt the instance, which is the operation my own documentation recommends.
The lesson is not "check your mounts." It is that a clean plan proves your code matches your state file, not that either matches reality — and anything that only runs at provision time sits outside that loop by design. Checking where your data physically lives is one command, and no amount of confidence in the code substitutes for running it.
Cutting over
DNS delegation is the one part Terraform genuinely cannot touch — pointing a registrar's nameservers at OCI has to be done by hand. I set GoDaddy's nameservers to the four OCI ones, waited for propagation, and ran Certbot to get a real certificate and move everything to HTTPS.
Immediately after cutover, curl from the server itself returned a 404. That turned out to be the instance's own resolver still holding a cached record pointing at Wix, not a problem with the application or Nginx. It cleared itself as propagation caught up; in the meantime I confirmed the server side was fine by connecting straight to the IP and skipping DNS entirely.
A second site moves in
With the blog settled, I added an entirely unrelated project to the same instance as separate Docker containers, with one Nginx in front routing by domain name — a React front end, a FastAPI backend and Postgres, deployed by a GitHub Actions workflow.
Two things from that half are worth passing on. A domain whose registrar is Cloudflare cannot have its nameservers delegated elsewhere; that is a registrar-level lock, not a settings problem, and the practical answer is to keep the records in Cloudflare rather than fight it. And Docker Compose treats a $ in a .env value as a variable reference, silently truncating everything after it — a generated password can end up shorter than what you typed, with no error and a container that starts perfectly happily. Verify what the container actually received, not what the file says.
The broader point: a single Always Free Arm instance will comfortably host more than one unrelated project, provided you are deliberate about what shares it and keep each project's data in clearly separated paths.
Where it landed
BA Soapbox now runs inside infrastructure I already manage — same codebase, same patterns, same visibility into what is actually happening underneath. No monthly platform fee and no black box. The debugging was not enjoyable at the time, but every one of those issues taught me something about how OCI's networking, and a bind-mounted container's user model, actually behave in a way no tutorial was going to.
If you are considering a similar move, my advice is to budget more time for the DNS and networking edges, and for how your application's container handles file ownership, than for the application itself. Grav, Docker Compose and Terraform all simply worked. Everything that cost me a weekend was underneath them.
Enjoy!