Kubernetes Production-Grade en un Solo Servidor: La Guía Completa
Por qué hacer esto
Un solo servidor. Todo el stack de producción. Sin excusas.
La idea es simple: montar un cluster Kubernetes que lleve el mismo stack que usarías en AKS o EKS — service mesh, TLS automático, observabilidad, seguridad en capas — pero en tu propio hierro. No es un minikube. No es un laboratorio de pruebas. Es un entorno que sirve workloads reales a usuarios reales.
¿Por qué un solo nodo? Porque para proyectos personales, side projects, y experimentación seria, un servidor de 15-30€/mes te da más que suficiente. Y la experiencia de operar este stack es transferible directamente a clusters multi-nodo en cloud.
El resultado final: un cluster donde desplegar una app nueva es crear un namespace, labelearlo para el service mesh, crear un HTTPRoute, y hacer kubectl apply. TLS automático, DNS automático, métricas, logs, y seguridad — todo ya está ahí.
El servidor y requisitos previos
Lo que necesitas:
- Servidor con Ubuntu 22.04+ — mínimo 4 vCPU y 8GB de RAM. Con todo el stack corriendo, el consumo base ronda los 4-5GB de RAM. Si vas a correr workloads encima, 16GB es más cómodo.
- cgroup v2 — Ubuntu 22.04+ lo trae habilitado por defecto. Verifica con
stat -fc %T /sys/fs/cgroup(debe devolvercgroup2fs). - Dominios en Cloudflare — necesarios para DNS-01 challenge (wildcard certs) y External DNS (creación automática de registros A).
- API Token de Cloudflare con permisos
Zone:DNS:Edit+Zone:Zone:Readpara todas las zonas que vayas a usar. - Tailscale instalado — para acceso seguro al API Server.
El API Server de Kubernetes (puerto 6443) nunca debe estar expuesto a internet. Usamos Tailscale como VPN para acceder desde fuera, y nftables para filtrar por interfaz de red. Si alguien llega a tu 6443, tiene las llaves del reino.
Base: kubeadm + containerd
Preparar el sistema
Primero, los módulos del kernel y parámetros de red que Kubernetes necesita:
# Módulos del kernel
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
# Parámetros de red
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
Instalar containerd
sudo apt-get update
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
# Habilitar SystemdCgroup — crítico para cgroup v2
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd
Si no habilitas SystemdCgroup = true, kubelet y containerd usarán drivers de cgroup diferentes. El resultado: pods que mueren aleatoriamente, kubelet que se reinicia, y horas de debugging. Es el error más común en instalaciones con kubeadm.
Instalar kubeadm, kubelet, kubectl
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.32/deb/Release.key | \
sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.32/deb/ /' | \
sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
Inicializar el cluster
sudo kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--service-cidr=10.96.0.0/16 \
--skip-phases=addon/kube-proxy
Saltamos kube-proxy con --skip-phases=addon/kube-proxy porque Calico en modo eBPF lo reemplaza completamente. eBPF maneja el balanceo de carga de servicios directamente en el kernel, sin las cadenas de iptables que kube-proxy genera. Mejor rendimiento, menos complejidad.
Después del init, configura el acceso y elimina el taint del nodo master:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
# Permitir que el nodo master ejecute workloads
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
En un cluster de un solo nodo, este último paso es obligatorio. Sin él, nada se programa en tu único nodo.
CNI: Calico con eBPF
Por qué Calico eBPF
Calico con eBPF te da tres cosas que el modo iptables no:
- Reemplaza kube-proxy — balanceo de carga de servicios en eBPF, sin iptables
- Mejor rendimiento — especialmente con muchos servicios, donde las cadenas de iptables se vuelven lineales
- Visibilidad de red nativa — Calico puede usar los datos de eBPF para políticas y métricas
Instalar el Tigera Operator
helm repo add projectcalico https://docs.tigera.io/calico/charts
helm repo update
kubectl create namespace tigera-operator
helm install calico projectcalico/tigera-operator \
--version v3.29.2 \
--namespace tigera-operator
Ahora viene un gotcha real: necesitas esperar a que el operator registre los CRDs antes de aplicar el recurso de Installation. Si aplicas el CR inmediatamente, falla silenciosamente o el recurso se queda en un estado inconsistente.
# Esperar a que los CRDs estén registrados
kubectl wait --for=condition=Established \
crd/installations.operator.tigera.io \
--timeout=120s
Aplicar el Installation CR con eBPF
# calico-installation.yaml
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
ipPools:
- name: default-ipv4-ippool
blockSize: 26
cidr: 10.244.0.0/16
encapsulation: VXLANCrossSubnet
natOutgoing: Enabled
nodeSelector: all()
linuxDataplane: BPF
hostPorts: Enabled
cni:
type: Calico
controlPlaneReplicas: 1
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
name: default
spec: {}
kubectl apply -f calico-installation.yaml
Con linuxDataplane: BPF, Calico opera enteramente en eBPF. Si tenías kube-proxy instalado (que no es el caso si seguiste los pasos anteriores), deberías parchar su DaemonSet para que no se programe:
# Solo si kube-proxy está instalado
kubectl patch ds -n kube-system kube-proxy \
-p '{"spec":{"template":{"spec":{"nodeSelector":{"non-calico": "true"}}}}}'
Service Mesh: Istio Ambient Mode
Por qué ambient y no sidecar
El modo sidecar inyecta un proxy Envoy en cada pod. Funciona, pero tiene overhead: cada pod consume más memoria, el startup es más lento, y hay toda una categoría de bugs relacionados con el orden de inicio del sidecar vs. la aplicación.
Ambient mode cambia el modelo: un daemon ztunnel por nodo maneja mTLS y L4 automáticamente para todos los pods del namespace. Si necesitas funcionalidades L7 (retries, timeouts, circuit breaking, observabilidad HTTP), despliegas un waypoint proxy compartido por namespace. Solo pagas el coste de L7 donde lo necesitas.
Instalar Istio
istioctl install --set profile=ambient -y
Para habilitar ambient en un namespace de aplicación:
kubectl label namespace mi-app istio.io/dataplane-mode=ambient
Para añadir un waypoint proxy compartido (L7):
istioctl waypoint apply --namespace mi-app --enroll-namespace
No apliques ambient a namespaces de infraestructura como kube-system, calico-system, tigera-operator, istio-system, o monitoring. El service mesh es para tus workloads de aplicación. Los componentes de infraestructura tienen sus propios mecanismos de seguridad y meterlos en el mesh solo introduce problemas.
Ingress: Gateway API + cert-manager + MetalLB
Esta es la capa que hace que tus servicios sean accesibles desde internet con TLS automático. Cuatro componentes trabajando juntos.
MetalLB
En cloud, un Service de tipo LoadBalancer obtiene una IP externa del proveedor. En bare metal, necesitas MetalLB para eso.
helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm install metallb metallb/metallb \
--namespace metallb-system \
--create-namespace
Espera a que los pods estén ready y luego configura el pool de IPs. En un solo servidor, el pool es la IP pública del servidor:
# metallb-config.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 203.0.113.10/32 # Tu IP pública
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default-pool
cert-manager + ClusterIssuer
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
El ClusterIssuer usa DNS-01 con Cloudflare para poder emitir certificados wildcard:
# cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: tu-email@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
Crea el Secret con el token de Cloudflare:
kubectl create secret generic cloudflare-api-token \
--namespace cert-manager \
--from-literal=api-token=TU_TOKEN_DE_CLOUDFLARE
Y el Certificate wildcard:
# wildcard-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-darkden-net
namespace: istio-system
spec:
secretName: wildcard-darkden-net-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- "darkden.net"
- "*.darkden.net"
Istio Gateway
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main-gateway
namespace: istio-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: istio
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: wildcard-darkden-net-tls
allowedRoutes:
namespaces:
from: All
HTTPRoute de ejemplo
# httproute-example.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mi-app
namespace: mi-app
spec:
parentRefs:
- name: main-gateway
namespace: istio-system
sectionName: https
hostnames:
- "app.darkden.net"
rules:
- backendRefs:
- name: mi-app-svc
port: 8080
External DNS
Para que los registros DNS se creen automáticamente cuando creas un HTTPRoute:
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns
helm repo update
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--set provider.name=cloudflare \
--set env[0].name=CF_API_TOKEN \
--set env[0].valueFrom.secretKeyRef.name=cloudflare-api-token \
--set env[0].valueFrom.secretKeyRef.key=api-token \
--set "sources={gateway-httproute}" \
--set policy=sync \
--set registry=txt \
--set txtOwnerId=k8s-cluster
Con esto, cada vez que creas un HTTPRoute con un hostname, External DNS crea el registro A en Cloudflare automáticamente. Cuando lo borras, lo limpia.
Datos: Operators de bases de datos
En un solo servidor puedes tener múltiples bases de datos gestionadas por operators de Kubernetes. No voy a entrar en la configuración de cada uno — cada operator merece su propio post — pero el punto es que el patrón funciona:
- CloudNativePG — PostgreSQL nativo de Kubernetes. Backups automatizados, failover (en multi-nodo), WAL archiving. Para aplicaciones nuevas, es la elección por defecto.
- MariaDB Operator — para aplicaciones que necesitan MySQL/MariaDB.
- Redis Operator (Spotahome) — para caching, sessions, y colas.
- Strimzi — Kafka para arquitecturas event-driven. Pesado en recursos, solo si realmente lo necesitas.
$ kubectl get pods -A | grep -E ‘cnpg|mariadb|redis’
cnpg-system cnpg-controller-manager-5d7f9b4c8-x2k9l 1/1 Running databases mi-app-pg-1 1/1 Running databases mi-app-mariadb-0 1/1 Running databases redis-node-0 1/1 Running
Observabilidad: Grafana + Loki + Prometheus
kube-prometheus-stack
Un solo Helm chart que te da Prometheus, Grafana, Alertmanager, node-exporter, y un montón de dashboards preconfigurados:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kube-prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set grafana.adminPassword=TU_PASSWORD_SEGURO \
--set prometheus.prometheusSpec.retention=30d \
--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=50Gi
Grafana se expone vía HTTPRoute con autenticación. Configura Google SSO (o el proveedor que prefieras) en los values de Grafana para no depender de usuario/password.
Loki + Promtail
Para logs centralizados:
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
helm install loki grafana/loki-stack \
--namespace monitoring \
--set promtail.enabled=true \
--set loki.persistence.enabled=true \
--set loki.persistence.size=20Gi
Dashboards recomendados
- Node Exporter Full (ID 1860) — métricas del servidor: CPU, RAM, disco, red
- Kubernetes / Pod Resources — consumo por pod y namespace
- Trivy Operator Vulnerabilities (ID 17813) — vulnerabilidades detectadas en imágenes
- Istio Mesh Dashboard — tráfico del service mesh, latencias, error rates
Seguridad: Defensa en profundidad
La seguridad no es un componente — es un stack de capas. Cada una cubre un ángulo diferente.
Trivy Operator
Escaneo automático de vulnerabilidades en todas las imágenes del cluster:
helm repo add aqua https://aquasecurity.github.io/helm-charts
helm repo update
helm install trivy-operator aqua/trivy-operator \
--namespace trivy-system \
--create-namespace \
--set trivy.ignoreUnfixed=true
Trivy crea VulnerabilityReport CRDs por cada workload. Puedes consultarlos con kubectl y visualizarlos en el dashboard de Grafana.
OPA Gatekeeper
Policy enforcement para prevenir configuraciones peligrosas:
helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm repo update
helm install gatekeeper gatekeeper/gatekeeper \
--namespace gatekeeper-system \
--create-namespace
Constraint templates que deberías tener desde el día uno:
- Require resource limits — ningún pod sin requests/limits de CPU y memoria
- Block
latesttag — forzar tags inmutables en imágenes - Require labels —
app.kubernetes.io/nameyapp.kubernetes.io/versionobligatorios - Block privileged containers — nadie corre como root a menos que sea explícitamente necesario
Falco
Detección de amenazas en runtime. Falco monitoriza syscalls y genera alertas cuando detecta comportamiento sospechoso (shell en contenedor, lectura de secrets, escritura en directorios sensibles):
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
helm install falco falcosecurity/falco \
--namespace falco \
--create-namespace \
--set falcosidekick.enabled=true \
--set falcosidekick.webui.enabled=true
La UI de Falco Sidekick nunca se expone a internet. Accédela solo a través de Tailscale o port-forward. Contiene información sensible sobre el comportamiento interno de tu cluster.
Network Policies
Default-deny en cada namespace de aplicación, con allows explícitos:
# default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: mi-app
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: mi-app
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# allow-istio.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-istio
namespace: mi-app
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: istio-system
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: istio-system
nftables para proteger el API Server
No uses iptables con filtrado por IP de origen si tienes Calico eBPF. El procesamiento eBPF modifica las IPs de origen mediante DNAT antes de que lleguen a las reglas de iptables, así que tus reglas de filtrado por source IP nunca van a matchear correctamente. Filtra por interfaz de red con nftables.
# /etc/nftables.conf
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# API Server: solo accesible desde localhost y Tailscale
tcp dport 6443 iifname "lo" accept
tcp dport 6443 iifname "tailscale0" accept
tcp dport 6443 drop
}
}
sudo systemctl enable nftables
sudo systemctl start nftables
Con esto, el API Server solo acepta conexiones desde el propio servidor (localhost) y desde la VPN de Tailscale. Todo lo demás se descarta.
Gestión: KEDA, Reloader, ESO
Tres herramientas que simplifican la operación diaria:
KEDA (Kubernetes Event-Driven Autoscaling) — escala pods basándose en métricas custom, longitud de colas, rate de peticiones, o cualquier fuente de datos. Más flexible que el HPA nativo.
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda \
--namespace keda \
--create-namespace
Reloader — monitoriza ConfigMaps y Secrets. Cuando cambian, hace rolling restart automático de los Deployments que los referencian. Sin Reloader, cambiar un ConfigMap requiere reiniciar pods manualmente.
helm repo add stakater https://stakater.github.io/stakater-charts
helm repo update
helm install reloader stakater/reloader \
--namespace reloader \
--create-namespace
External Secrets Operator — sincroniza secrets desde backends externos (Vault, Infisical, AWS Secrets Manager) hacia Kubernetes Secrets. Preparado para cuando quieras centralizar la gestión de secrets fuera del cluster.
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace
El resultado final
Con todo instalado, esto es lo que tienes corriendo:
| Capa | Componentes |
|---|---|
| Runtime | containerd, kubelet, kubeadm |
| Networking | Calico eBPF (CNI + reemplazo de kube-proxy) |
| Service Mesh | Istio ambient (ztunnel + waypoints) |
| Ingress | Gateway API + MetalLB + External DNS |
| TLS | cert-manager + Let’s Encrypt + Cloudflare DNS-01 |
| Datos | CloudNativePG, MariaDB Operator, Redis Operator |
| Observabilidad | Prometheus, Grafana, Alertmanager, Loki, Promtail |
| Seguridad | Trivy, Gatekeeper, Falco, Network Policies, nftables |
| Gestión | KEDA, Reloader, External Secrets Operator |
$ kubectl get pods -A —field-selector status.phase=Running -o json | jq ‘.items | length’ 47
47 pods corriendo en un solo servidor. El consumo base del stack de infraestructura ronda los 4-5GB de RAM y 1.5-2 vCPUs. El resto está disponible para tus workloads.
Desplegar una app nueva es:
# Crear namespace y habilitarlo para el service mesh
kubectl create namespace mi-nueva-app
kubectl label namespace mi-nueva-app istio.io/dataplane-mode=ambient
# Desplegar el waypoint si necesitas L7
istioctl waypoint apply --namespace mi-nueva-app --enroll-namespace
# Aplicar manifests (Deployment, Service, HTTPRoute)
kubectl apply -f mi-nueva-app/
DNS, TLS, métricas, logs, network policies, escaneo de vulnerabilidades — todo ya está ahí esperando.
Conclusión
Este setup no es un homelab de juguete. Es el mismo patrón arquitectónico que usarías en producción cloud, adaptado a un solo nodo. La diferencia de coste es brutal: 15-30€/mes por un servidor dedicado vs. cientos de euros en un cluster gestionado con el mismo stack.
Los gotchas están en los detalles que no aparecen en la documentación oficial:
- CRDs que no se registran a tiempo y causan fallos silenciosos en los operators
- eBPF que rompe el filtrado por source IP en iptables
- Istio init containers que interfieren con operators de bases de datos que necesitan inicializar el sistema de archivos antes de que la red esté disponible
- containerd sin SystemdCgroup que causa reinicios aleatorios de pods
Cada uno de estos te puede costar horas de debugging si no los conoces de antemano. Ahora los conoces.