I recently ran into a claim: Docker Compose is outdated and K3s is the king for my 1GB VPS.
At the same time, docker-compose.py is effectively deprecated, with Compose now shipped as a built-in docker compose command. That alone is not a problem, but it raised a reasonable question: has the role of Docker Compose changed, or is this noise from the Kubernetes church?
This post is about self-hosted setups:
- one or a few VPS
- price sensitivity
- limited ops time, bordering on laziness
- preference for boring, anti-hype infra
Within that landscape, I compare:
- Docker Compose
- K3s
- Podman + Quadlet
- ...
- Profit!
Docker Compose
No need to explain, here's the docker-compose.yaml.
Compose baseline: app + Valkey
services: redis: image: valkey/valkey:latest restart: unless-stopped command: valkey-server --port 6379 ports: - "127.0.0.1:6379:6379" healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 5s timeout: 3s python: build: context: . dockerfile: Dockerfile.python restart: always environment: REDIS_HOST: redis ports: - "8080:8080" healthcheck: test: - CMD - python - -c - "import http.client,sys; c=http.client.HTTPConnection('127.0.0.1',8080,timeout=2); c.request('GET','/healthz'); r=c.getresponse(); sys.exit(0 if r.status==200 else 1)" interval: 5s timeout: 3s depends_on: redis: condition: service_healthy Dockerfile.python
ARG BASE_IMAGE=python:3.14-slim-trixie ARG UV_VERSION=0.9.18-python3.14-trixie-slim FROM ghcr.io/astral-sh/uv:$UV_VERSION AS uv_carrier FROM $BASE_IMAGE AS builder COPY --from=uv_carrier /usr/local/bin/uv /usr/local/bin/ RUN uv venv /opt/venv ENV PATH=/opt/venv/bin:$PATH COPY ./requirements.txt requirements.txt RUN uv pip install -r requirements.txt FROM $BASE_IMAGE WORKDIR /opt/app ENV PYTHONUNBUFFERED=1 \ PATH=/opt/venv/bin:$PATH COPY --from=builder /opt/venv /opt/venv COPY . ./ CMD ["python", "-m", "python.main"] python/main.py
import asyncio import os from redis.asyncio import Redis REDIS_HOST = os.getenv("REDIS_HOST", "localhost") REDIS_PORT = os.getenv("REDIS_PORT", "6379") SRV_HOST = "0.0.0.0" SRV_PORT = 8080 def handler(redis: Redis): async def fn(_: asyncio.StreamReader, w: asyncio.StreamWriter): try: await redis.ping() response = ( f"HTTP/1.1 200 OK\r\n" f"Content-Type: text/plain\r\n" f"Content-Length: 0\r\n" f"\r\n" f"" ) w.write(response.encode()) await w.drain() finally: w.close() await w.wait_closed() return fn async def main(): redis = Redis(host=REDIS_HOST, port=int(REDIS_PORT)) server = await asyncio.start_server(handler(redis), host=SRV_HOST, port=SRV_PORT) try: async with server: await server.serve_forever() finally: await redis.aclose() if __name__ == "__main__": asyncio.run(main()) docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS example-python-1 example-python "python -m python.ma…" python 2 hours ago Up 2 hours (healthy) example-redis-1 valkey/valkey:latest "docker-entrypoint.s…" redis 2 hours ago Up 2 hours (healthy) 127.0.0.1:6379->6379/tcp Pros
It works. It's simple, supports health checks, and has restart policies.
Cons
Compose is not part of the system, has poor integration with systemd and journald. Broad permissions, a rootful daemon, and a continuous CPU/RAM tax.
Health checks are mostly informational and don't automatically trigger restarts.
It's a fine baseline for a single VPS.
K3s
A few words from the official sites:
What is K3s? Lightweight Kubernetes.
Easy to install, half the memory, all in a binary of less than 100 MB.
Setup is curl -sfL https://get.k3s.io | sh -. I already like it: run as root, trust me 😈
Cluster overview
root@qemu:/home/user# systemctl status k3s ● k3s.service - Lightweight Kubernetes Loaded: loaded (/etc/systemd/system/k3s.service; enabled; preset: enabled) Active: active (running) since Fri 2025-12-19 14:57:54 UTC; 4min 49s ago Docs: https://k3s.io Process: 1140 ExecStartPre=/sbin/modprobe br_netfilter (code=exited, status=0/SUCCESS) Process: 1141 ExecStartPre=/sbin/modprobe overlay (code=exited, status=0/SUCCESS) Main PID: 1143 (k3s-server) Tasks: 90 Memory: 1.4G (peak: 1.5G) CPU: 35.823s CGroup: /system.slice/k3s.service ├─1143 "/usr/local/bin/k3s server" ├─1168 "containerd " ├─1732 /var/lib/rancher/k3s/data/7b97d417878d140a607ac677a82c2562e863279de825743bbdb137cff0b48877/bin/containerd-shim-runc-v2 -namespace k8s.io -id ba799f11f2b2d99b2a153257380ff9755> ├─1766 /var/lib/rancher/k3s/data/7b97d417878d140a607ac677a82c2562e863279de825743bbdb137cff0b48877/bin/containerd-shim-runc-v2 -namespace k8s.io -id ff37d03c651fc8d57defe3d5493195fdd> ├─1777 /var/lib/rancher/k3s/data/7b97d417878d140a607ac677a82c2562e863279de825743bbdb137cff0b48877/bin/containerd-shim-runc-v2 -namespace k8s.io -id a2b64c80ff6b0546113338e4426c31ccb> ├─2773 /var/lib/rancher/k3s/data/7b97d417878d140a607ac677a82c2562e863279de825743bbdb137cff0b48877/bin/containerd-shim-runc-v2 -namespace k8s.io -id a0538126449c9c4ff76f05aec8f2da487> └─2862 /var/lib/rancher/k3s/data/7b97d417878d140a607ac677a82c2562e863279de825743bbdb137cff0b48877/bin/containerd-shim-runc-v2 -namespace k8s.io -id 5be4562c4eb8e79d0f8282ed74eb04b76> Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845233 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="tlsoptions.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845249 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="apiportalauths.hub.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845267 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="apiportals.hub.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845284 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="middlewaretcps.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845297 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="ingressrouteudps.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845313 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="apiplans.hub.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845329 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="apiratelimits.hub.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845341 1143 resource_quota_monitor.go:227] "QuotaMonitor created object count evaluator" resource="serverstransporttcps.traefik.io" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845489 1143 shared_informer.go:350] "Waiting for caches to sync" controller="resource quota" Dec 19 14:58:59 qemu k3s[1143]: I1219 14:58:59.845520 1143 shared_informer.go:357] "Caches are synced" controller="resource quota" root@qemu:/home/user# sudo kubectl get all -n kube-system NAME READY STATUS RESTARTS AGE pod/coredns-6d668d687-6lhpl 1/1 Running 0 4m48s pod/helm-install-traefik-crd-rsd4s 0/1 Completed 0 4m48s pod/helm-install-traefik-ttdrr 0/1 Completed 1 4m48s pod/local-path-provisioner-869c44bfbd-k6ct2 1/1 Running 0 4m48s pod/metrics-server-7bfffcd44-4xtb7 1/1 Running 0 4m48s pod/svclb-traefik-091968ea-wb98r 2/2 Running 0 4m17s pod/traefik-865bd56545-b5qkm 1/1 Running 0 4m17s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kube-dns ClusterIP 10.43.0.10 <none> 53/UDP,53/TCP,9153/TCP 4m51s service/metrics-server ClusterIP 10.43.249.39 <none> 443/TCP 4m51s service/traefik LoadBalancer 10.43.154.80 10.0.2.15 80:30206/TCP,443:31853/TCP 4m17s NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE daemonset.apps/svclb-traefik-091968ea 1 1 1 1 1 <none> 4m17s NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/coredns 1/1 1 1 4m51s deployment.apps/local-path-provisioner 1/1 1 1 4m51s deployment.apps/metrics-server 1/1 1 1 4m51s deployment.apps/traefik 1/1 1 1 4m17s NAME DESIRED CURRENT READY AGE replicaset.apps/coredns-6d668d687 1 1 1 4m48s replicaset.apps/local-path-provisioner-869c44bfbd 1 1 1 4m48s replicaset.apps/metrics-server-7bfffcd44 1 1 1 4m48s replicaset.apps/traefik-865bd56545 1 1 1 4m17s NAME STATUS COMPLETIONS DURATION AGE job.batch/helm-install-traefik Complete 1/1 34s 4m51s job.batch/helm-install-traefik-crd Complete 1/1 33s 4m51s K3s deployment manifests
Here's the same Valkey & Python app with the same Dockerfile. Let's deploy it. Helm isn't included by default, so I'll use raw YAML manifests.
namespace.yaml
kubectl apply -f - <<'YAML' apiVersion: v1 kind: Namespace metadata: name: selfhosted YAML deployment-valkey.yaml
kubectl apply -f - <<'YAML' apiVersion: apps/v1 kind: Deployment metadata: name: redis namespace: selfhosted spec: replicas: 1 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: containers: - name: redis image: valkey/valkey:latest args: ["valkey-server", "--port", "6379"] ports: - containerPort: 6379 readinessProbe: exec: command: ["sh", "-c", "valkey-cli ping | grep -q PONG"] initialDelaySeconds: 2 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 livenessProbe: exec: command: ["sh", "-c", "valkey-cli ping | grep -q PONG"] initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 5 YAML service-valkey.yaml
kubectl apply -f - <<'YAML' apiVersion: v1 kind: Service metadata: name: redis namespace: selfhosted spec: selector: app: redis ports: - name: redis port: 6379 targetPort: 6379 type: ClusterIP YAML deployment-python.yaml
kubectl apply -f - <<'YAML' apiVersion: apps/v1 kind: Deployment metadata: name: python namespace: selfhosted spec: replicas: 1 selector: matchLabels: app: python template: metadata: labels: app: python spec: containers: - name: python image: localhost/local/python-app:dev imagePullPolicy: IfNotPresent env: - name: REDIS_HOST value: redis - name: REDIS_PORT value: "6379" ports: - containerPort: 8080 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 5 YAML service-python.yaml
kubectl apply -f - <<'YAML' apiVersion: v1 kind: Service metadata: name: python namespace: selfhosted spec: selector: app: python ports: - name: http port: 8080 targetPort: 8080 type: ClusterIP YAML ingress.yaml
kubectl apply -f - <<'YAML' apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: python namespace: selfhosted spec: rules: - host: python.local http: paths: - path: / pathType: Prefix backend: service: name: python port: number: 8080 YAML additional trick to bring docker image into ctr
podman build -f Dockerfile.python -t local/python-app:dev . podman save --format docker-archive local/python-app:dev | sudo k3s ctr images import - All of this recreates what Docker Compose already gives me:
kubectl -n selfhosted get pods -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES python-59b66ccf6b-xwng2 1/1 Running 0 3m51s 10.42.0.12 qemu <none> <none> redis-68764c78c5-xjplm 1/1 Running 0 30m 10.42.0.9 qemu <none> <none> On a single VPS.
K3s verdict: full cluster tax
Pros: it provides /usr/local/bin/k3s-uninstall.sh.
Jokes aside: on the one hand it gives me the full Kubernetes experience, even with built-in ingress controller, so I could reuse existing manifests for a full-fledged cluster and scale things up (no, but belief helps, see non-clusterized deployment-valkey.yaml).
On the other hand, I pay high operational costs and high CPU/RAM overhead. Will I ever recoup that investment?
Podman + Quadlet
The dark horse. I'd never heard of it before I started writing this article, but it looks the most promising.
First, Podman is an engine that runs containers without a permanent privileged daemon. It uses OCI runtimes, is rootless by design, and stays Docker-compatible enough for this use.
Second, Quadlet generates systemd units, so containers integrate cleanly with systemd and journald.
Quadlet deployment units
Podman itself already exists in Ubuntu repos (no need to add Docker's signing key and repo).
- as root:
sudo apt update && sudo apt install podman - as regular user:
podman build -f Dockerfile.python -t local/python-app:dev .(without Docker's required manual group/permission management; images differ from one user to another) mkdir -p ~/.config/containers/systemd-
podman network create selfhosted(strange, but the default Podman network has DNS disabled)
Preparation done. Add the units.
~/.config/containers/systemd/redis.container
[Unit] Description=Valkey (redis) Wants=network-online.target After=network-online.target [Container] Image=docker.io/valkey/valkey:latest Network=selfhosted Exec=valkey-server --port 6379 PublishPort=127.0.0.1:6379:6379 HealthCmd=valkey-cli ping HealthInterval=5s HealthTimeout=3s HealthRetries=5 [Service] Restart=always RestartSec=3 # MemoryMax=768M # MemorySwapMax=768M # CPUQuota=85% # TasksMax=128 [Install] WantedBy=default.target ~/.config/containers/systemd/python.container
[Unit] Description=Python app Wants=redis.service After=redis.service [Container] Image=localhost/local/python-app:dev Network=selfhosted Environment=REDIS_HOST=systemd-redis PublishPort=127.0.0.1:8080:8080 HealthCmd=python -c "import http.client,sys; c=http.client.HTTPConnection('127.0.0.1',8080,timeout=2); c.request('GET','/healthz'); r=c.getresponse(); sys.exit(0 if r.status==200 else 1)" HealthInterval=5s HealthTimeout=3s HealthRetries=5 [Service] Restart=always RestartSec=3 # MemoryMax=768M # MemorySwapMax=768M # CPUQuota=85% # TasksMax=128 [Install] WantedBy=default.target Validate configs by /usr/libexec/podman/quadlet --user --dryrun. If it looks good, run systemctl --user daemon-reload.
systemctl --user status redis python --no-pager
● redis.service - Valkey (redis) Loaded: loaded (/home/user/.config/containers/systemd/redis.container; generated) Active: active (running) since Sat 2025-12-20 13:32:38 UTC; 8min ago Main PID: 85139 (conmon) Tasks: 21 (limit: 9370) Memory: 8.6M (peak: 20.0M) CPU: 2.047s CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/redis.service ├─libpod-payload-e8c29da1a2f6c4bb032304f7dfbc5a767e026092c4a2fcbc830e58565f972f54 │ └─85141 "valkey-server *:6379" └─runtime ├─85124 rootlessport ├─85130 rootlessport-child └─85139 /usr/bin/conmon --api-version 1 -c e8c29da1a2f6c4bb032304f7dfbc5a767e026092c4a2fcbc830e58565f972f54 -u e8c29da1a2f6c4bb032304f7dfbc5a767e026092c4a2fcbc830e58565f972f54 -r … Dec 20 13:32:38 qemu podman[85050]: 2025-12-20 13:32:38.398858374 +0000 UTC m=+0.013421623 image pull d422224bf6e2d405c51aa2fa092855478543f21872a9b1a8125d184095f5aa25 docker.io/v…ey/valkey:latest Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.497 * oO0OoO0OoO0Oo Valkey is starting oO0OoO0OoO0Oo Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.497 * Valkey version=9.0.1, bits=64, commit=00000000, modified=0, pid=1, just started Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.497 * Configuration loaded Dec 20 13:32:38 qemu podman[85050]: 2025-12-20 13:32:38.496286417 +0000 UTC m=+0.110849666 container start e8c29da1a2f6c4bb032304f7dfbc5a767e026092c4a2fcbc830e58565f972f54 (image…T=redis.service) Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.497 * monotonic clock: POSIX clock_gettime Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.497 * Running mode=standalone, port=6379. Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.498 * Server initialized Dec 20 13:32:38 qemu systemd-redis[85139]: 1:M 20 Dec 2025 13:32:38.498 * Ready to accept connections tcp Dec 20 13:32:38 qemu redis[85050]: e8c29da1a2f6c4bb032304f7dfbc5a767e026092c4a2fcbc830e58565f972f54 ● python.service - Python app Loaded: loaded (/home/user/.config/containers/systemd/python.container; generated) Active: active (running) since Sat 2025-12-20 13:32:38 UTC; 8min ago Main PID: 85244 (conmon) Tasks: 16 (limit: 9370) Memory: 38.3M (peak: 46.6M) CPU: 2.721s CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/python.service ├─libpod-payload-8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 │ └─85246 python -m python.main └─runtime ├─85218 rootlessport ├─85232 rootlessport-child └─85244 /usr/bin/conmon --api-version 1 -c 8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 -u 8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 -r … Dec 20 13:32:38 qemu systemd[782]: Starting python.service - Python app container (rootless)... Dec 20 13:32:38 qemu podman[85146]: 2025-12-20 13:32:38.516813999 +0000 UTC m=+0.021024836 container create 8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 (imag…=python.service) Dec 20 13:32:38 qemu podman[85146]: 2025-12-20 13:32:38.568795185 +0000 UTC m=+0.073006064 container init 8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 (image=….version=1.33.7) Dec 20 13:32:38 qemu systemd[782]: Started python.service - Python app container (rootless). Dec 20 13:32:38 qemu podman[85146]: 2025-12-20 13:32:38.583186772 +0000 UTC m=+0.087397609 container start 8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 (image….version=1.33.7) Dec 20 13:32:38 qemu python[85146]: 8a390a010658563033af8b8e7be8c0984e03eb109eee54fdf2e7a7688ee7ca90 Dec 20 13:32:38 qemu podman[85146]: 2025-12-20 13:32:38.508078113 +0000 UTC m=+0.012288991 image pull 5ab432c25a0bea88ed2ac7b636a0a44eeff6e5cb44c03f63e275ac736475c4eb localhost/l…l/python-app:dev Hint: Some lines were ellipsized, use -l to show in full. The result: container isolation without the overhead and complexity. It runs as a regular user. Updates are predictable via systemctl --user restart python.service. Health checks restart the service, and logs rotate out of the box.
Verdict: Podman + Quadlet wins on a 1GB VPS
| Metric | Docker Compose | K3s | Podman + Quadlet |
|---|---|---|---|
| Idle RAM (Engine) | ~181MB | ~1.4GB | ~0MB (daemonless) |
| Orchestrator | Docker daemon | "Control plane"© | systemd |
| Permissions | Rootful (default) | Rootful | Rootless (default) |
| Observability | docker logs | kubectl logs | journalctl |
| LoC | 34 | 124 | 34 |
I started on the Docker Compose side, mostly intending to joke about Kubernetes being dragged into everything. Instead, I ended up replacing Docker with Podman + Quadlet on my VPS in the name of simplicity!
I intentionally omitted persistent volumes, because it would make the comparison (especially K3s vs Quadlet) unfair in terms of operational cost.
The surprise: everything worked fine on arm64 (macOS → qemu-system-aarch64 → ubuntu-24.04.3-live-server-arm64).


