Skip to content

How much cloud fits into one server?

An unfamiliar on-premise machine looked like a constraint. It became a test of whether years of infrastructure work could collapse into one repeatable deployment.

Published
Updated
Reading time
11 min read

In May 2025, I had to deploy Plantronic into an on-premise environment I did not know.

One server. Corporate networking. Controlled egress. An application, a database, ingress, migrations, backups, monitoring, and a deployment path that had to work from staging through production.

This could have become a bespoke server project: install packages, write service units, copy configuration, arrange startup order, add a reverse proxy, improvise database backups, and document which commands an operator must remember.

Instead, years of infrastructure work collapsed into one small k3s installation and a set of Terraform compositions.

That result is why I pride myself on running the things I build. I do not consider an application complete when it works on my laptop or when a container image exists. I want to understand how it starts, how it receives configuration, how it migrates data, how it recovers, and how another person can reproduce the deployment without borrowing my memory.

The deployment was unique; most pieces were not

I did not arrive with a ready-made “Plantronic platform.” I arrived with a portfolio of smaller, declarative parts developed across earlier work.

My public Terraform Modules repository contains modules for web workloads, PostgreSQL, GitLab Agent, monitoring, ingress, cloud resources, secrets, jobs, and related infrastructure. One day before the Plantronic deployment milestone, I committed the initial reusable Kubernetes modules that the project composed.

Plantronic selected and connected those pieces:

  • a containerized Phoenix application;
  • an explicit migration step;
  • a Kubernetes Deployment and Service;
  • ingress and TLS integration;
  • PostgreSQL with persistent storage;
  • scheduled logical backups and retention;
  • GitLab Agent for controlled deployment access;
  • monitoring and adjacent operational services.

None of these modules was remarkable alone. Their value came from combining them without removing their boundaries.

That is the compounding effect I care about. Knowledge does not compound merely because I remember how I solved a problem before. It compounds when the old solution has a stable interface, remains readable, and can participate in a new system without being copied into it.

Why one server still became a Kubernetes cluster

A single-node Kubernetes installation sounds contradictory to some people. Kubernetes is associated with fleets, autoscaling, and highly available control planes. Plantronic had one machine. Why place a cluster API between the application and Systemd?

Because node count was not the problem I wanted Kubernetes to solve.

I wanted a consistent application runtime and a declarative workload model. I wanted an image, configuration, secrets, migrations, services, jobs, ingress, health behavior, and rollout state to be described with the same primitives in staging and production. I wanted GitLab to interact with the deployment without exposing a raw cluster endpoint through the client’s firewall.

k3s packages the Kubernetes control plane and required runtime components for smaller environments. On one server it remains one failure domain, but it gives me the Kubernetes API and controllers without pretending that I am operating a large managed cluster.

That distinction matters:

  • supervision: yes, failed workloads can restart and declared state can be reconciled;
  • repeatability: yes, the same resources can be created again;
  • host-level high availability: no, one broken server is still one broken server;
  • durability: only as good as storage, backups, restore procedures, and host recovery.

I chose single-node k3s for operational consistency, not for an imaginary availability claim.

Systemd is not the loser

The strongest counterproposal is good: build an Elixir release, run it under Systemd, run PostgreSQL as a host service, and keep the deployment small.

Systemd is an excellent process supervisor. It has restart policies, dependency ordering, timers, socket activation, credentials, resource controls, and extensive execution sandboxing. Podman Quadlet offers an appealing middle ground by declaring containers as Systemd-managed units.

Docker Compose in production is another reasonable middle path when the stack is co-located and its lifecycle remains small. It preserves container images and a multi-service declaration without adding a Kubernetes control plane. I prefer admitting when that is enough instead of turning Kubernetes experience into a requirement.

For one application process and one host, I would seriously consider one of those designs. Kubernetes would add an API server, certificates, networking, storage abstractions, controllers, and upgrade work without automatically producing value.

Plantronic crossed my threshold because it was not one process:

  • application release;
  • migration lifecycle;
  • database and persistent storage;
  • ingress;
  • recurring backup jobs;
  • secret and runtime configuration;
  • monitoring;
  • staging and production workloads;
  • remote deployment through a constrained network.

I could model every one of those with Systemd units, scripts, container commands, timers, and proxy configuration. I would then own a Plantronic-specific orchestration system assembled from excellent host primitives.

I already owned the equivalent Kubernetes abstractions as reusable Terraform modules. In my situation, direct Systemd would have been the bespoke choice.

There is another useful truth hiding here: Systemd still supervises k3s. I did not replace it. I moved application orchestration behind a boundary that could remain consistent across hosts and environments. Systemd owns the node service; Kubernetes owns workloads; OTP owns processes inside the Elixir release.

I prefer these nested responsibilities over asking one layer to understand every failure.

Containerizing Elixir without fighting OTP

Elixir already has supervision trees. Kubernetes does not improve a GenServer restart by killing the entire pod, and I do not want it trying.

I divide the responsibilities differently:

  • OTP supervises application processes and internal dependencies;
  • the Elixir release defines the runnable application artifact;
  • the container fixes the OS-level runtime and native dependencies;
  • Kubernetes supervises the container and its relationship to services;
  • k3s itself remains a Systemd-supervised node service.

Phoenix documents deployment through OTP releases. A controlled build can produce the release, a smaller runtime image can contain it, and runtime configuration can arrive when the container starts. The same image moves through environments; the release tag and configuration identify what actually runs.

For Plantronic, containerization supplied valuable guarantees in an unfamiliar host:

  • exact Erlang and Elixir runtime expectations;
  • known native libraries;
  • explicit listening port and process command;
  • reproducible startup behavior;
  • one image promoted rather than a host mutated in place.

Database migrations remained an explicit deployment step. I prefer that to hiding schema changes inside normal application startup. The deployment can order migration before the new workload, report failure, and avoid having every replica race to alter the database.

Containerization did not make the BEAM reliable. OTP already did much of that work. It made the boundary around the BEAM release portable.

Twelve-Factor, applied rather than recited

The Twelve-Factor App is sometimes invoked as a cloud slogan. I find it more useful as a set of pressure tests.

Config

Environment-specific values should not be compiled into the release. Kubernetes configuration and secrets let the same image receive the database address, runtime identity, and deployment-specific values at startup.

Backing services

PostgreSQL is attached infrastructure, not a hidden directory inside the application container. That separation makes its persistence, backup, monitoring, and recovery responsibilities explicit.

Build, release, run

The image is the build artifact. A tagged image plus runtime configuration forms a release. Kubernetes runs that release. Terraform describes the desired resources around it.

Processes and disposability

The Phoenix application should start predictably, handle termination, and recover without depending on writable container-local state. Kubernetes can replace the process because durable state lives elsewhere.

Dev/prod parity

I do not need every developer laptop to run a production cluster. I do want staging and production to share image, configuration, migration, service, and job concepts. That parity catches deployment mistakes before production without pretending environments are identical.

None of these factors requires Kubernetes. That is important. Kubernetes was useful because my existing modules already expressed these properties coherently.

Terraform all the way up to the application

I want infrastructure changes to look like code changes: proposed, reviewed, planned, applied, and recorded.

The Plantronic composition referenced reusable modules rather than copying their resources. The application module owned common deployment behavior. The PostgreSQL module owned the database chart and persistence interface. The GitLab Agent module owned its Helm release. Project-level Terraform supplied the choices that were genuinely specific to Plantronic.

This creates two useful levels of change:

  1. improve a reusable module when several applications need the same capability;
  2. change the Plantronic composition when the requirement belongs only to Plantronic.

That boundary prevents two failures I see often. A giant universal platform makes every application wait for abstractions it does not need. Fully bespoke Terraform makes every project rediscover secrets, rollouts, services, and jobs.

I prefer small modules with boring inputs. Composition is where client context enters.

The initial server bootstrap still established the substrate: operating system preparation, k3s, certificates, registry access, and network integration. I do not want to pretend a few documented host steps are already Infrastructure as Code. My direction is to push stable host knowledge into declarative automation while keeping credentials out of code and state exposure under control.

Above that substrate, application infrastructure belonged in Terraform. No hand-maintained Deployment YAML, no deployment recipe that existed only in shell history.

GitLab Agent across a controlled boundary

Traditional CI deployment often assumes that a runner can reach the Kubernetes API directly. That is uncomfortable in an on-premise network and frequently incompatible with firewall policy.

The GitLab Agent for Kubernetes changes the direction. The cluster-side agent opens a bidirectional channel to GitLab’s agent server, KAS. GitLab documents this as a way to communicate with clusters behind firewall or NAT.

That fit Plantronic’s egress-controlled environment. I could define a narrow outbound path instead of publishing the cluster API for inbound CI traffic. The same Terraform composition installed the Helm release and connected deployment automation to the cluster.

I use “air-gapped” carefully. A truly air-gapped network cannot talk to GitLab.com; no agent changes that fact. It needs mirrored images and artifacts, a reachable self-managed control plane, or a controlled transfer process. k3s documents air-gap installation, which makes the substrate adaptable, but egress-controlled and air-gapped are different operating modes.

I value the architecture because it crosses organizational boundaries cleanly. Client networking can remain restrictive. Deployment can remain automated. The interface between both concerns is explicit.

A practical guide to deciding

I would not recommend single-node k3s merely because a project uses containers. I would ask these questions.

Choose direct Systemd or Quadlet when

  • there is one host and only a few processes;
  • host-specific integration is desirable;
  • the team knows Systemd better than Kubernetes;
  • staging/production Kubernetes parity has little value;
  • container networking, jobs, secrets, and ingress remain simple;
  • no reusable Kubernetes platform already exists.

Consider single-node k3s when

  • the application includes several independently managed workloads;
  • images and Kubernetes resources already exist elsewhere;
  • jobs, migrations, services, ingress, secrets, and health behavior need one declarative model;
  • controlled deployment access benefits from an in-cluster agent;
  • staging and production should share orchestration primitives;
  • the team can operate and upgrade the cluster;
  • one host failure domain is acceptable and honestly documented.

In either case

  • make backups and test restores;
  • keep credentials out of repositories;
  • pin and plan runtime upgrades;
  • document substrate recovery;
  • monitor the host, not only the application;
  • define who owns the deployment after handover.

Technology choice cannot compensate for missing operational ownership.

What I bring when I bring infrastructure

The Plantronic deployment did not prove that Kubernetes belongs on every server. It proved something more useful to me: a broad portfolio of composable infrastructure can make an unfamiliar boundary feel familiar without erasing what is different about it.

I brought lessons from cloud orchestration, container builds, Kubernetes resources, Terraform modules, Elixir releases, database operations, and constrained networks. I did not combine them into a private platform that only I could operate. I assembled public, declarative modules into a client-specific composition from staging to production.

That is the compounding effect I want from experience. Every project should leave behind more than memories and copied snippets. It should sharpen a module, an interface, a runbook, or a decision rule that improves the next project.

I pride myself on running what I build because deployment is where architectural claims meet reality. Configuration must arrive. State must survive. Processes must restart. Networks must permit exactly enough. Another person must be able to see what exists.

One server was enough to make all of those concerns concrete. Years of cloud work were what made one server feel small.