Self-Hosting Like a Pro, Part 3: Namespace Isolation, Databases and Shipping a Real App on K3s
In Part 1 we hardened a fresh Ubuntu VPS. In Part 2 we installed Docker, K3s and Traefik v3 with automatic HTTPS. The cluster was ready but empty. In this part we give it a real job: hosting multiple domains on a single 2 vCPU, 8 GB VPS, each one isolated, resource capped and served over TLS.
By the end of this article, a complete production stack will be running: a Next.js portfolio, a Ghost blog backed by MySQL, a shared Redis instance and a NestJS microservice, all in their own namespaces behind Traefik.
Why namespaces matter on a single node
It is tempting to throw everything into the default namespace when you only have one server. Resist that temptation. Namespaces give you three things that matter even on a single node:
- Blast radius control. A misbehaving deployment in one namespace cannot exhaust the resources of another if quotas are in place.
- Clean secrets separation. Database credentials for one project are simply not visible to pods of another project.
- A future proof layout. When a new project ships, it gets a namespace and a quota. Nothing else changes.
My layout looks like this:
ns-portfolio the portfolio stack (Next.js, Ghost, MySQL, worker)
ns-databases shared services (Redis today, PostgreSQL later)
ns-monitoring Prometheus, Grafana, Loki (Part 4)
ns-webapp reserved, empty for now
ns-saas reserved, empty for now
ns-misc reserved for small future projects
Creating namespaces ahead of time costs nothing. An empty namespace consumes zero resources, and when the code for a future project is ready, deployment becomes a single kubectl apply.
Resource quotas: the math of a small VPS
With 8 GB of RAM total, the operating system and K3s itself need roughly 1 GB. That leaves about 7 GB to distribute. Here is the budget I settled on:
apiVersion: v1
kind: ResourceQuota
metadata:
name: quota
namespace: ns-portfolio
spec:
hard:
limits.cpu: "1800m"
limits.memory: 2Gi
requests.cpu: "500m"
requests.memory: 768Mi
pods: "10"
persistentvolumeclaims: "5"
Two lessons I learned the hard way here.
Lesson one: limits are permissions, not reservations. Only requests are actually reserved by the scheduler. Limits are ceilings. It is fine for the sum of all limits to exceed physical memory, because pods rarely peak at the same time. Being too strict with limits caused me real pain, as you will see below.
Lesson two: leave headroom for rolling updates. A rolling update briefly runs the old pod and the new pod side by side. If your quota is sized exactly for steady state, every deployment will fail with a FailedCreate event saying the quota is exceeded. My first rollout of a small worker got stuck for exactly this reason. Size your quota for steady state plus your largest single pod.
One more trap: if you attach a LimitRange or a strict quota to a namespace where you install third party Helm charts, make sure every container in those charts declares resources. Grafana ships init containers without resource specs, and my quota silently blocked every pod creation until I inspected the ReplicaSet events with kubectl describe. When pods refuse to appear and there is no error in sight, always describe the ReplicaSet, not the Deployment.
TLS with cert-manager and Let's Encrypt
cert-manager was installed in Part 2. Now we wire it to Let's Encrypt with two ClusterIssuers, one for staging and one for production:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@yourdomain.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: traefik
Always test with the staging issuer first. Let's Encrypt production has rate limits, and a misconfigured DNS record can burn through them quickly. Once a staging certificate issues correctly, switch the issuerRef to production.
Requesting a certificate is then declarative:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: blog-tls
namespace: ns-portfolio
spec:
secretName: blog-tls-secret
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- blog.yourdomain.com
In my case both certificates were issued in under two minutes after the DNS records propagated.
Databases: shared versus dedicated
Not every project deserves its own database server on a small VPS. My rule:
- Shared instance for small projects. One PostgreSQL, several databases, one user per database, cross access revoked.
- Dedicated instance for anything with real growth or compliance needs. My future SaaS gets its own PostgreSQL in its own namespace.
- Ghost gets MySQL because that is what Ghost officially supports in production. SQLite is fine on a laptop, not on a server.
The important security detail: database Services are headless and never exposed through the ingress.
apiVersion: v1
kind: Service
metadata:
name: mysql-ghost-svc
namespace: ns-portfolio
spec:
selector:
app: mysql-ghost
ports:
- port: 3306
clusterIP: None
Apps reach the database through cluster internal DNS, for example mysql-ghost-svc.ns-portfolio.svc.cluster.local. Nothing on the public internet can ever reach port 3306.
Another operational rule worth writing down: single node databases use the Recreate deployment strategy, never RollingUpdate. Two MySQL pods pointing at the same PersistentVolumeClaim at the same time is a recipe for corruption.
Startup ordering with init containers
Kubernetes starts everything at once by default. Ghost crashes if MySQL is not ready, so we make dependencies explicit:
initContainers:
- name: wait-mysql
image: busybox:1.36
command:
- sh
- -c
- |
until nc -z mysql-ghost-svc.ns-portfolio.svc.cluster.local 3306; do
echo "Waiting for MySQL..."; sleep 3
done
Simple, dependency free, and it removes an entire class of crash loops at startup.
The PVC masking trap
This one cost me an evening. My Ghost theme is packaged inside a custom Docker image, copied to the standard themes directory:
FROM ghost:5-alpine
COPY . /var/lib/ghost/content/themes/mytheme
It worked in docker compose on my laptop. In production the theme was invisible. Why? Because Kubernetes mounts a PersistentVolumeClaim over /var/lib/ghost/content, and a volume mount completely hides whatever the image had at that path. The image content is still there, you just cannot see it.
The fix is a two step delivery. First, the image stores the theme outside the mounted path:
FROM ghost:5-alpine
COPY --chown=node:node . /theme-dist/mytheme
Second, an init container copies it into the volume on every pod start:
initContainers:
- name: copy-theme
image: ghcr.io/you/ghost-mytheme:latest
command:
- sh
- -c
- |
mkdir -p /var/lib/ghost/content/themes
rm -rf /var/lib/ghost/content/themes/mytheme
cp -r /theme-dist/mytheme /var/lib/ghost/content/themes/mytheme
volumeMounts:
- name: content
mountPath: /var/lib/ghost/content
Every image rebuild now updates the theme automatically at the next rollout. Remember this pattern whenever an image needs to ship files into a path that a volume will cover.
Routing with Traefik IngressRoutes
Each domain maps to a Service through an IngressRoute. Middlewares add the production polish: HTTP to HTTPS redirect, www to apex redirect, security headers and rate limiting.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: blog-https
namespace: ns-portfolio
spec:
entryPoints: [websecure]
routes:
- match: Host(`blog.yourdomain.com`)
kind: Rule
services:
- name: ghost-svc
port: 2368
middlewares:
- name: security-headers
tls:
secretName: blog-tls-secret
A word of warning about Content Security Policy. My first strict CSP included connect-src 'self', which quietly broke the Unsplash integration inside the Ghost admin panel. Admin interfaces of CMS platforms talk to many external APIs. Either allow the specific endpoints you need or apply the strict CSP only to your public frontend and use a relaxed one on the admin subdomain.
What is running now
At this point the cluster hosts:
- Next.js portfolio on the apex domain, served over TLS
- Ghost blog with a custom theme on the blog subdomain, backed by MySQL
- A NestJS webhook worker, internal only, no public route
- Redis in the shared namespace, password protected, headless
- Automatic certificate renewal for everything
All of it fits comfortably in the resource budget, with room left for the monitoring stack.
In Part 4 we add eyes to the system: Prometheus, Grafana and Loki, plus the hard won lessons of running a full observability stack on 8 GB of RAM.