I Stopped Paying for a Managed CMS. Here's What I Built Instead.
Part 1: The Decision, the Stack, and Everything That Broke Before It Worked
by Sai Harsha Kondaveeti · May 2026 · Garvaman
I run multiple platforms. Garvaman Intelligence Labs for technical writing and AI systems research. Ceneways Horizon as the parent company. RAG Axis as an open-source library. Agiorcx as an agent orchestration platform. Each of them needs a blog, a documentation section, or both.
The obvious answer — the one everyone reaches for — is a managed CMS. Contentful. Sanity. Hygraph. Pay monthly, get a nice editor, connect your frontend, ship content.
I tried that route mentally. And something kept bothering me.
I was going to pay for multiple CMS instances, or awkwardly try to wedge all my projects into one managed account, or deal with separate content silos that couldn't talk to each other. And every bit of that was going to be running on someone else's infrastructure, subject to their pricing changes, their API limits, their terms of service.
I already had a server.
That changed the calculation entirely.
The Server Was the Variable
Here's the thing: I had provisioned a Hetzner AX41-NVMe dedicated server — 64GB RAM, AMD Ryzen 5 3600, 1TB NVMe — to host my production backend services. The server was already running. The fixed cost was already sunk.
A managed CMS on top of that becomes a hard-to-justify recurring cost when you have 64 gigabytes of RAM sitting mostly idle.
So the question stopped being "which managed CMS should I use?" and became "which self-hosted CMS makes the most sense for my stack?"
I evaluated three options seriously:
Ghost — Beautiful for pure blogging. Not designed for multi-project content management. Strong opinions about structure that don't bend easily. Out.
Directus — Powerful, flexible, more of a headless data platform than a CMS. Excellent if you want full database control. The flexibility is also the complexity — it takes time to set up the right content model. Worth revisiting later.
Strapi — Headless CMS designed explicitly for the use case I had: multiple content types, API-first, admin panel for non-technical contributors, clean content type builder. It's not the flashiest option, but it's mature and it solves the problem cleanly.
Strapi won. Not because it's the best CMS that exists, but because it's the right CMS for what I was building — a content layer that could serve multiple platforms from one place, with a sane admin UI, over a well-structured REST API.
The Architecture I Was Building Toward
Before writing a single command, I sketched out what I actually wanted:
PostgreSQL 17 (host server)
├── cehorizon_cms ← Strapi CMS (all platforms)
├── auramark_db ← Auramark backend
├── agiorcx_db ← Agiorcx platform
└── ragaxis_db ← RAG Axis library
Strapi (Docker container)
└── Serves content for:
├── garvamanai.com ← GET /api/garvamanai/essays
├── cenewayshorizon.com ← GET /api/ceneways-content (future)
└── ragaxis.dev ← GET /api/ragaxis-docs (future)
Nginx (host)
└── cms.cenewayshorizon.com → Strapi:1337
One database server, shared across all projects. One CMS instance, serving all platforms through separate collection types. Each frontend fetches from its own endpoint. Clean separation without duplicated infrastructure.
This is the part most self-hosting tutorials skip: the architecture decision matters more than the installation steps. Installing Strapi takes an afternoon. Deciding how it fits into your broader infrastructure is the real work.
Setting Up the Server
The server was already provisioned and hardened — non-root user, SSH key auth, UFW firewall, the basics. What it didn't have was Docker CE installed properly.
Ubuntu 24.04 ships with a Docker package from the default apt repo. Don't use it. It's an older build that ships without the Compose v2 plugin, which you need. The fix is to remove the Ubuntu-packaged Docker and install from Docker's official apt source:
sudo apt-get remove -y docker.io docker-compose containerd runc sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) \ signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin
After this, docker compose version returns the v2 plugin. That's what you want.
The PostgreSQL Decision
Here's where I made a deliberate choice that most tutorials won't make: I installed PostgreSQL directly on the host, not as a Docker container alongside Strapi.
The common approach is to run everything in Docker — a Strapi container and a Postgres container together in a Compose stack. It works. But it means your database lives inside a container, and if you want to add another project later, you either add another containerised Postgres (wasteful) or share the container across projects (messy).
I wanted a single PostgreSQL 17 instance — running as a proper system service — that every project on the server could use. Each project gets its own database. One server-level process manages them all. Clean, standard, how databases are supposed to work.
# Install from PostgreSQL's official apt repo, not Ubuntu's default sudo apt install -y curl ca-certificates sudo curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ -o /etc/apt/keyrings/postgresql.asc echo "deb [signed-by=/etc/apt/keyrings/postgresql.asc] \ https://apt.postgresql.org/pub/repos/apt \ $(lsb_release -cs)-pgdg main" | \ sudo tee /etc/apt/sources.list.d/pgdg.list sudo apt update sudo apt install -y postgresql-17
PostgreSQL 17.10 installed and running as a system service. Then I created the superuser and the CMS database:
sudo -u postgres psql
CREATE USER garvaman WITH SUPERUSER PASSWORD 'your-password'; CREATE DATABASE cehorizon_cms OWNER garvaman;
One thing I learned here, the hard way: don't type passwords directly in terminal commands. They end up in shell history. Use the psql prompt instead — it doesn't log the contents of interactive sessions.
The Strapi Dockerfile: Three Problems
The repo I cloned had a Dockerfile that looked reasonable. It wasn't.
Problem 1: NODE_ENV missing from the build stage.
The Dockerfile set NODE_ENV=production in the final runner stage, but not in the builder stage. Strapi's build process checks for this — without it, it asked interactively whether to install dependencies, which makes the Docker build hang indefinitely.
Problem 2: --omit=dev removed the wrong packages.
The deps stage ran npm install --omit=dev, which skips devDependencies. The problem: Strapi's admin UI packages (react, react-dom, react-router-dom, styled-components) were listed as devDependencies. The build failed because they weren't there.
Fix: remove --omit=dev from the deps stage. Install everything, build, then the runner stage only copies the built output — you don't end up shipping dev packages.
Problem 3: The sharp module.
This one was more subtle. Strapi uses sharp for image processing. sharp is a native module that compiles differently depending on the OS and C library it's running on.
Alpine Linux (which node:18-alpine uses) uses musl libc, not glibc. If sharp was installed on a non-Alpine machine (or without the right flags), the compiled binary won't work inside the Alpine container. The fix is to explicitly rebuild it for the target platform during the Docker build:
FROM node:18-alpine AS deps WORKDIR /opt/app RUN apk add --no-cache python3 make g++ vips-dev COPY package*.json ./ RUN npm install --ignore-scripts RUN npm rebuild sharp --platform=linuxmusl --arch=x64 FROM node:18-alpine AS builder WORKDIR /opt/app ENV NODE_ENV=production COPY --from=deps /opt/app/node_modules ./node_modules COPY . . RUN npm run build FROM node:18-alpine AS runner RUN apk add --no-cache dumb-init vips WORKDIR /opt/app ENV NODE_ENV=production COPY --from=builder /opt/app/node_modules ./node_modules COPY --from=builder /opt/app/build ./build COPY --from=builder /opt/app/config ./config COPY --from=builder /opt/app/src ./src COPY --from=builder /opt/app/public ./public COPY package*.json ./ RUN mkdir -p public/uploads && chown -R node:node /opt/app USER node EXPOSE 1337 ENTRYPOINT ["dumb-init", "--"] CMD ["npm", "start"]
The key additions: vips-dev in the deps stage (build-time dependency for sharp), vips in the runner stage (runtime dependency), and explicit npm rebuild sharp --platform=linuxmusl --arch=x64.
Connecting Docker to the Host Database
This is the part that burned the most time, and it's worth being precise about.
When Strapi runs in a Docker container, it's isolated from the host network. To reach PostgreSQL — which is running as a host service on port 5432 — the container needs to connect via the Docker bridge network's gateway IP, not localhost.
The mistake is assuming this is always 172.17.0.1. It isn't. Docker creates different bridge networks for different Compose stacks, each with its own subnet. You need to find the actual gateway for your specific network:
docker network inspect <your-network-name> \ --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
In my case, the gateway was 172.18.0.1 — not 172.17.0.1. Setting DATABASE_HOST=172.17.0.1 in .env produced connection timeouts. Setting it to 172.18.0.1 got me to the next problem immediately.
The next problem: pg_hba.conf.
PostgreSQL's client authentication config only allowed connections from localhost (via Unix socket) by default. Docker containers connect over TCP, from a different subnet. I needed to add:
host all all 172.18.0.0/16 scram-sha-256
And update postgresql.conf to listen on all interfaces:
listen_addresses = '*'
And allow port 5432 through UFW for the Docker subnet:
sudo ufw allow from 172.18.0.0/16 to any port 5432
After all three of those: nc -zv 172.18.0.1 5432 from inside the container returned open. The auth error that followed was straightforward — wrong password in .env after rotating credentials. Fixed in seconds.
What Finally Started
After fixing the Dockerfile, the network path, the pg_hba config, the firewall rule, and the environment variables — Strapi started:
┌────────────────────┬──────────────────────────────────────┐
│ Time │ Sat May 30 2026 13:51:21 GMT+0000 │
│ Launched in │ 1208 ms │
│ Environment │ production │
│ Version │ 4.25.0 (node v18.20.8) │
│ Database │ postgres │
└────────────────────┴──────────────────────────────────────┘
Not glamorous. But accurate. The server is running, the database is connected, the environment is production.
Part 2 covers what comes next: Nginx, SSL, DNS, and actually connecting a live frontend to pull articles from the CMS API.
What I'd Do Differently
A few things I learned that aren't in any tutorial:
Rotate credentials before they appear in a terminal command. Passwords typed into bash commands end up in shell history and, if you're doing a build-in-public session, in the conversation transcript. Use psql interactively for credential operations.
Check your Docker network subnet before hardcoding IPs. 172.17.0.1 is a common assumption. It's wrong half the time. Inspect the actual network.
Don't trust --ignore-scripts blindly. Some npm packages (like sharp) have install scripts that do meaningful platform-specific compilation. Skipping them and not rebuilding explicitly produces runtime failures that look nothing like the root cause.
The Dockerfile is a build specification, not an afterthought. If you're deploying to a different platform than you're developing on (Mac → Alpine Linux container), assume your native modules will need explicit rebuild steps.
Sai Harsha Kondaveeti builds production AI systems. He is the creator of RAG Axis, Agiorcx, Auramark, and Cespace. Follow his work at garvamanai.com.
Continue reading: Part 2 — Nginx, SSL, and Connecting the Frontend →