The Coding Lab
<- All posts

From Docker Compose to K3s: Evolving a Home-Server Deployment

Why a single-host IMDb Clone deployment moved from Docker Compose to K3s, Ansible, Argo CD, encrypted GitOps, managed TLS, and observable release flows.

In 2023, I deployed my IMDb Clone to one Ubuntu home server with Docker Compose, Traefik, dynamic DNS, and two forwarded router ports. It was a deliberately small system, and it worked.

That first design is documented in How to Cheaply Host Your Full-Stack Side Project. The important parts were already present: containers as release artifacts, a reverse proxy as the public boundary, persistent volumes for state, and a private network between services.

Three years later, the problem was no longer how do I start these containers? It was a different set of questions:

  • Can I reconstruct the server from versioned configuration?
  • Which exact frontend and backend images should be running?
  • Who corrects a manual change made on the host?
  • How do encrypted secrets participate in the same delivery flow?
  • Who owns certificates, health checks, resource budgets, and monitoring?
  • Can CI publish a release without receiving credentials for my home cluster?

The number of containers had increased, but scale was not the reason to adopt Kubernetes. The migration was about replacing imperative host state with explicit ownership and reconciliation.

The result is a single-node K3s cluster bootstrapped by Ansible and reconciled by Argo CD. Traefik still receives the same forwarded ports. The application still ships as container images. PostgreSQL, OpenSearch, and object storage still live on one physical machine.

The application data stack evolved along the way as well: MySQL became PostgreSQL, Elasticsearch became OpenSearch, and MinIO was replaced by the S3-compatible RustFS. Those were application and operational decisions, not capabilities that Kubernetes provided automatically.

The machinery changed; the failure domain did not.

The current IMDb Clone frontend running on the home-server platform

The current IMDb Clone frontend. The platform work in this article exists to make this application reproducible and operable; Kubernetes is the means, not the product.

The migration was staged, not rewritten

I did not replace the Compose deployment with a finished platform in one step. The Git history shows a sequence of small operational capabilities added over two weeks.

Timeline from the 2023 Compose deployment through the staged K3s migration in May 2026

The order mattered:

  1. Ansible installed K3s and Argo CD.
  2. SOPS and age made encrypted configuration compatible with GitOps.
  3. The release workflow moved from mutable image selection to versioned tags and immutable digests.
  4. cert-manager took ownership of certificate issuance and renewal.
  5. Prometheus and Grafana made the node, workloads, backend, and database observable.
  6. OpenSearch completed the movement of the stateful application stack into the cluster.

This was an evolutionary migration. The public entry path continued to use the same domain and router design. Each step introduced one responsibility and one new failure mode that could be understood before adding the next.

Compose was not the mistake

Docker Compose remains a strong choice for a small deployment on one machine. It describes containers, networks, volumes, environment variables, and restart policies in a compact format. There is little control-plane machinery, and a developer familiar with Docker can understand the system quickly.

I could also have improved the original deployment without Kubernetes:

  • Ansible could have installed Docker and rendered the Compose files.
  • systemd could have supervised a deployment service.
  • versioned image tags could have replaced latest.
  • a separate monitoring stack could have observed the host and containers.
  • a secret manager could have replaced local environment files.

For a handful of stable services, that may still be the better design.

K3s became worthwhile because these requirements were no longer independent additions. I wanted application workloads, supporting services, ingress, certificates, encrypted configuration, health policies, and observability to use one declarative API and one reconciliation model.

That is the decision boundary I would reuse: adopt an orchestrator when the value of a shared control plane exceeds the cost of operating the control plane itself.

Separate bootstrap from reconciliation

The current platform has three flows with different owners.

Ansible bootstrap, GitOps release, and public runtime flows for the home-server K3s deployment

Ansible owns the transition from a generic Ubuntu host to a working control plane. Argo CD owns the desired Kubernetes resources after bootstrap. CI owns verification and artifact publication. Traefik owns the public request boundary. The stateful services own application data, which requires a separate backup process.

Making those ownership boundaries explicit prevents one automation layer from becoming an unstructured script that does everything.

The Ansible entry point is intentionally small:

---
- name: Bootstrap homelab Kubernetes control plane
  hosts: homelab
  become: true
  roles:
    - common
    - k3s
    - argocd

The common role installs the host packages. The k3s role installs a pinned K3s version, enables its service, waits for the Kubernetes API, and waits for the node to become ready. The argocd role installs a pinned Argo CD release, configures SOPS decryption, and finally applies the root application.

That final step is the handover. Ansible does not render and apply every application workload itself. Once Argo CD is running, Git becomes the record of what Kubernetes resources should exist.

This split also makes recovery easier to describe:

new Ubuntu host
  → run the Ansible playbook
  → obtain a ready K3s control plane and Argo CD
  → let Argo CD reconstruct the Git-managed resources
  → restore application data through its own recovery process

The final arrow is deliberately separate. Recreating Kubernetes objects is not the same as restoring PostgreSQL or object-storage contents.

Why K3s fit one home server

K3s is still Kubernetes: Deployments, Services, StatefulSets, Ingress resources, persistent-volume claims, controllers, and the Kubernetes API behave as the platform model. The useful difference for this project is packaging.

A K3s server is not only a remote control plane. By default, it also runs the kubelet, container runtime, and cluster networking required to host Pods. An agent adds worker capacity without adding datastore or control-plane components. The official process diagram makes that distinction visible:

K3s server and agent processes, including the control plane, container runtime, networking, and Pods

K3s server and agent internals. This single-node deployment uses the server side and schedules the application Pods there; it does not require a separate agent. Source: K3s architecture documentation, © K3s contributors, licensed under CC BY 4.0. Converted from SVG to WebP without changing the content.

K3s includes the container runtime, cluster networking, CoreDNS, Traefik, a service load-balancer controller, metrics-server, and the local-path storage provisioner unless those components are disabled. The K3s packaged-components documentation describes that default set.

For one node, this avoided assembling a distribution, ingress controller, and basic storage provisioner separately. I kept the bundled Traefik and local-path storage enabled.

The network edge therefore stayed pleasantly boring:

public DNS
  → home router forwards TCP 80 and 443
  → Traefik on the K3s node
  → ClusterIP services inside the cluster

PostgreSQL, OpenSearch, and the object store are not exposed through router rules. The frontend, backend, and public media endpoint are routed through Ingress resources. Operator interfaces receive stricter access controls than the public application.

K3s reduced assembly work; it did not create redundancy. There is still one node, one disk subsystem, one router, one power connection, and one residential internet connection.

Make Git the deployment record

The old deployment could pull latest and restart containers. That was operationally simple, but latest did not prove which build the host was running. A host script also left deployment state partly in command history and partly in the current machine.

The new release starts with the repository’s VERSION file. Changing that file triggers the release workflow:

  1. Validate the semantic version and ensure its Git tag does not already exist.
  2. Build and test the Spring Boot backend.
  3. Install, generate, and build the React frontend.
  4. Publish Linux AMD64 container images with the release tag.
  5. Resolve each image’s registry digest.
  6. Update the Kubernetes manifests with tag-plus-digest references.
  7. Validate the complete Kustomize render.
  8. Commit the manifest change and create the release tag.

The public v1.0.1 release run is a concrete record of this workflow completing successfully. The version used below is therefore tied to an actual release rather than an invented placeholder.

A workload then identifies both a human-readable version and an immutable artifact:

containers:
  - name: backend
    image: registry.example/imdb-backend:v1.0.1@sha256:...

The tag communicates intent. The digest prevents that intent from silently resolving to different bytes later.

Most importantly, CI does not connect to the Kubernetes API. It builds artifacts and updates Git. Argo CD runs inside the cluster, observes the commit, and pulls the desired state inward. Cluster credentials are not part of the GitHub Actions release boundary.

Reconcile drift—but choose deletion separately

The root Argo CD Application points at the home-cluster Kustomize tree and enables automated synchronization:

spec:
  source:
    path: infrastructure/clusters/home/apps
    plugin:
      name: sops-kustomize
  syncPolicy:
    automated:
      prune: false
      selfHeal: true

According to the Argo CD automated-sync documentation, self-healing lets Argo CD correct live drift from the desired state. If someone manually edits a managed Deployment, reconciliation can restore the Git version.

prune: false draws a different boundary. Removing a resource from this root application’s source does not automatically authorize deletion of its live counterpart. That lowers the blast radius of a mistaken removal, but it also means retirement requires an explicit cleanup decision. Some child applications can choose their own pruning policy when their resources are safe to recreate.

This is a useful distinction: creating and updating automatically does not require deleting automatically. Reconciliation policy should encode the failure you are most concerned about, not merely enable every automation switch.

Keep secrets encrypted before they reach Git

Kubernetes Secret values are usually base64-encoded, not encrypted. A normal Secret manifest committed to a repository would therefore expose its plaintext to everyone who can read the repository.

This deployment uses SOPS with age recipients. Secret values are encrypted before commit, while keys and non-sensitive structure remain reviewable. Files using the *.sops.yaml convention participate in the same Kustomize tree as the other resources.

The age private key stays outside Git. During bootstrap, Ansible verifies that the local key exists and writes it into the Argo CD namespace with logging disabled for that task. An Argo CD repository-server plugin decrypts the protected files only while rendering the application.

This solves repository exposure, not every secret problem:

  • the decryption key must be backed up in a secure, independent location;
  • cluster administrators with sufficient access can still read the resulting Kubernetes Secrets;
  • key rotation and access review remain operational tasks;
  • moving a leaked plaintext value into SOPS does not erase it from Git history—the credential must be rotated.

The useful property is that encrypted configuration can now be reviewed, versioned, and reconciled without placing plaintext credentials in the repository.

Let ingress declare certificate ownership

The Compose deployment configured ACME directly on Traefik. In K3s, ingress routing and certificate management are separate controllers.

The project installs cert-manager and a Let’s Encrypt ClusterIssuer. Public Ingress resources name that issuer and the Secret that should receive the certificate:

metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - movies.example.com
      secretName: movie-app-tls

cert-manager observes the annotation, creates or maintains the Certificate, completes the ACME challenge, and writes the certificate and private key into the named Secret. The cert-manager Ingress documentation describes this ingress-shim flow.

The cluster uses an HTTP-01 solver assigned to the Traefik ingress class. Public port 80 must therefore reach Traefik for issuance and renewal, while port 443 carries normal HTTPS traffic. Kubernetes changed who owns the certificate lifecycle, but it did not remove DNS, the residential public address, or router forwarding from the request path.

Put runtime assumptions into manifests

Compose restart policies could restart failed containers. Kubernetes added a richer vocabulary for when a workload should receive traffic, when it should restart, and how much of the node it may consume.

The frontend and backend Deployments define resource requests and limits plus readiness and liveness probes. An unready Pod is removed from Service endpoints; a repeatedly failed liveness probe asks the kubelet to restart the container. The Kubernetes probe documentation separates those responsibilities.

The current application probes check whether named TCP ports accept connections. That is useful, but intentionally modest evidence: an open management port does not prove that PostgreSQL, OpenSearch, or every upstream dependency is healthy. A deeper probe can provide more confidence, but it can also create restart cascades when a dependency is temporarily unavailable.

Resource requests and limits are equally important on one small node. OpenSearch reserves a fixed JVM heap, the backend needs enough memory for the JVM, and Prometheus consumes storage and memory of its own. Budgets make those assumptions visible before one workload consumes everything available.

Synchronization waves provide coarse deployment ordering: namespaces and supporting controllers appear before applications, while public Ingress resources are applied after their Services. Ordering is helpful during reconciliation, but it is not a substitute for applications that tolerate temporarily unavailable dependencies.

Observability became part of the platform

The cluster deploys the Prometheus community monitoring stack through an Argo CD child application. Prometheus collects node and Kubernetes workload metrics, kube-state data, PostgreSQL metrics, and the Spring Boot Actuator endpoint. Grafana dashboards cover the system, backend, and database.

That makes several questions answerable without starting an SSH session:

  • Is the node under memory or disk pressure?
  • Which Pod is restarting or failing readiness?
  • Is the backend’s request latency or error rate changing?
  • Are PostgreSQL connections, locks, or storage growing unexpectedly?
  • Are the expected workloads present and Ready?

The current configuration deliberately has Alertmanager disabled. It provides useful inspection and dashboards, but it is not yet a complete paging or notification system. An external probe is still needed to detect failures in DNS, the router, the ISP, or TLS from the same perspective as a user.

This is another example of describing the actual capability rather than treating “Prometheus installed” as synonymous with “operations solved.”

Persistent volumes are still local data

PostgreSQL runs as a standalone instance. OpenSearch runs as a single-node StatefulSet. The S3-compatible object store also runs in standalone mode. Their persistent-volume claims use K3s local-path storage.

Those volumes survive Pod replacement. They do not survive every disk, filesystem, host, or operator failure. The scheduler also cannot move a local volume to a second node that does not exist.

The recovery contracts remain different:

StoreRoleRecovery expectation
PostgreSQLTransactional source of truthRegular off-host backups and tested restores
OpenSearchDerived search projectionRecreate the index and rebuild it from PostgreSQL
Object storagePosters, backdrops, and uploaded mediaCopy objects to independent storage and verify restoration
Git repositoryDesired Kubernetes configurationReconstruct resources, not application data

Kubernetes makes workload state declarative. It does not turn local disks into backups or a single node into a highly available system.

What the migration bought—and what it cost

The K3s platform improved several properties:

  • the host bootstrap is repeatable and versioned;
  • Git records the intended manifests and exact application image digests;
  • Argo CD detects and corrects managed drift;
  • secrets can join the delivery model without plaintext in Git;
  • certificates have a dedicated controller and renewal lifecycle;
  • probes, resource budgets, and rollout ordering are explicit;
  • monitoring is deployed and reconciled with the rest of the platform;
  • CI publishes releases without direct cluster access.

It also introduced real costs:

  • the Kubernetes API, Argo CD, cert-manager, the SOPS plugin, and Helm charts must be upgraded and understood;
  • CRD and controller ordering can fail in ways Compose does not have;
  • debugging now crosses Git, Argo CD, Kubernetes events, controllers, Pods, Services, and ingress;
  • chart versions and container digests add a larger dependency surface;
  • the control plane consumes resources on the same small machine as the application.

For one stable container and one database, this would be hard to justify. For this project, the value came from consolidating an expanding set of operational responsibilities behind one desired-state model.

A practical decision rule

I would keep Compose when:

  • the service graph is small and changes rarely;
  • a short, documented deployment command is sufficient;
  • configuration can be managed safely on one host;
  • operating Kubernetes would add more failure modes than it removes.

I would consider K3s when:

  • several controllers or supporting services already want Kubernetes-native APIs;
  • drift detection and reconciliation have operational value;
  • release artifacts and desired state need a clear audit trail;
  • ingress, certificates, secrets, resources, and monitoring benefit from one model;
  • learning and operating Kubernetes is itself part of the project’s purpose.

I would not treat single-node K3s as the answer when the real requirement is high availability. That problem needs additional failure domains, replicated storage or managed data services, redundant networking and power, and a tested recovery design.

The important decision is not choosing Kubernetes. It is being able to explain why the simpler system stopped meeting the requirements, which responsibility each new component owns, and which risks remain after the migration.

Further reading