Blog

From VPS to Kubernetes: Containerizing the app in user space

By  
Sami Ekblad
Sami Ekblad
·
On Jul 21, 2026 1:07:58 PM
·

In the previous VPS post we deployed URL shortener service the old-fashioned way: clone the repository, build it on the server, wire up Jetty server, configure nginx by hand, and run Certbot. Quite many steps and moving parts, and everything living as bare processes on a single Ubuntu machine.

And I got some feedback about that. While I believe it is a great way to understand what is really going on under the hood, maybe not the most modern way to do it. I had already discovered UpCloud also offers a K8s upgrade and a Kubernetes control plane to deploy and manage containerized applications. However, this felt like too big a change. I wanted to make a smaller step and test the containerized deployment.

This time we will package each service into its own container image and hand the running of them over to MicroK8s, a lean, single-node Kubernetes distribution that installs straight onto our existing Ubuntu server with one command. Reading this article, no new machine needed, no cloud provider managed control plane. We can use the same UpCloud server we already have. And if you wish you can do that locally also.

The difference

Last time the processes started by hand, no automatic restart on reboot, Certbot certificates renewed by a cron job. It works, but the server is doing everything and you need to SSH in whenever you want to change something. This time the setup will look like this:

Architecture diagram showing a MicroK8s cluster hosting an Admin UI pod and a URL shortener pod behind an Ingress controller. The URL shortener stores data in an EclipseStore persistent volume, while the Admin UI communicates with it over port 9090.

Containers started and restarted automatically by Kubernetes, certificates renewed by cert-manager, no SSH session required to redeploy, just push a new image and roll out.

More resources

The infrastructure does not come from free. To get things up and running I upgraded the VPS instance to 4GB and 2 cores recommended by Canonical. That is 6 times the original cost, but for real production applications I think still a reasonable 18€/month.

Screenshot of an UpCloud VPS configuration showing 2 CPU cores, 4 GB RAM, 60 GB storage, and a monthly price of €18.00.

Installing MicroK8s on VPS

We are still working with the same Ubuntu Server 24.04 LTS machine from last time. SSH in as root and install MicroK8s via snap:

snap install microk8s --classic --channel=1.35/stable microk8s status --wait-ready

Now enable the three addons we need for this deployment:

microk8s enable ingress microk8s enable cert-manager microk8s enable hostpath-storage

That single line gives us a local container image registry, a traefik-based Ingress controller, and cert-manager for automatic TLS certificates. In the pure VPS approach we install and configure nginx and Certbot separately.

One of the nice things is that you can run MikroK8s with only user permissions. Just add your own user to the microk8s group so you can run commands without root access or sudo:

usermod -aG microk8s url-app-user su url-app-user

You will also want Docker on the build machine (your laptop or a CI server) to build the images. On the Ubuntu VPS itself you only need to have MicroK8s and ssh access.

Prepare app code for the cluster setup

Container packaging surfaces problems that were invisible in the single-machine setup. Both services were running on the same host, and localhost was perfectly fine. In Kubernetes each pod gets its own private network name and cannot reach each other in localhost anymore.

The admin API was listening to localhost only.

ShortenerServer.java hardcoded the admin HTTP server to listen on localhost:9090. A container that only listens on loopback cannot be reached by any other pod. We bind address from an environment variable instead, with 0.0.0.0 as the default:

// In ShortenerServer.init(hostRedirect, portRedirect, persistent):
String adminHost = System.getenv().getOrDefault("ADMIN_SERVER_HOST", "0.0.0.0");
this.serverAdmin = HttpServer 
.create(new InetSocketAddress(adminHost, 
ADMIN_SERVER_PORT), 0);

The REST client was also hardcoded localhost:9090.

URLShortenerClient.java by default created a client pointing at http://localhost:9090. We update it to read the server address from environment variables, falling back to the old defaults so it does not break our local development:

public URLShortenerClient() {     
String adminUrl    = System.getenv() 
.getOrDefault("ADMIN_SERVER_URL",ADMIN_SERVER_URL);     
String redirectUrl = System.getenv()		
.getOrDefault("REDIRECT_SERVER_URL",DEFAULT_SERVER_URL);     
this(adminUrl, redirectUrl);   
}

The Kubernetes manifests will supply the right values via environment variables. Local development keeps working exactly as before without any extra configuration.

Packaging into container images

Each service gets its own Dockerfile using a multi-stage build. The first stage does Maven build inside a JDK image, and the second stage copies only the compiled artifact into a lean JRE image. I like this approach as this way the build tools never end up in the image that actually runs in production and you get clean builds also locally.

The server image

urlshortener-server/Dockerfile:

# Stage 1: Build
FROM maven:3.9-eclipse-temurin-26 AS build
WORKDIR /workspace

COPY pom.xml .
COPY urlshortener-core/pom.xml urlshortener-core/pom.xml
COPY urlshortener-server/pom.xml urlshortener-server/pom.xml
COPY urlshortener-client/pom.xml urlshortener-client/pom.xml
COPY urlshortener-ui/pom.xml urlshortener-ui/pom.xml

RUN mvn -pl urlshortener-core,urlshortener-server -am dependency:go-offline -q

COPY urlshortener-core/src urlshortener-core/src
COPY urlshortener-server/src urlshortener-server/src

RUN mvn -pl urlshortener-core,urlshortener-server -am -DskipTests clean package

# Stage 2: Runtime
FROM eclipse-temurin:26-jre
WORKDIR /app

COPY --from=build /workspace/urlshortener-server/target/urlshortener.jar app.jar

VOLUME /app/data

EXPOSE 8081 9090

# containers receive traffic over the pod/bridge network - bind all interfaces
# (the application defaults are localhost for plain `java -jar` runs)
ENV REDIRECT_SERVER_HOST=0.0.0.0 \
ADMIN_SERVER_HOST=0.0.0.0

ENTRYPOINT ["java", "-jar", "app.jar"]

The VOLUME /app/data declaration is where EclipseStore writes its persistent data. Kubernetes will mount a PersistentVolume there so the URL mappings survive pod restarts. The ENTRYPOINT passes 0.0.0.0 as the redirect server bind address, matching what we did for the admin server above.

The UI image

urlshortener-ui/Dockerfile needs three stages because the Vaadin production build is separate from the Maven build, and we bring in the Jetty runtime without shipping wget in the final image:

# Stage 1: Download Jetty
FROM eclipse-temurin:26-jre AS jetty-download

ARG JETTY_VERSION=12.0.36

RUN apt-get update \
&& apt-get install -y --no-install-recommends wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*

RUN wget -q https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/${JETTY_VERSION}/jetty-home-${JETTY_VERSION}.tar.gz \
-O /tmp/jetty.tar.gz \
&& mkdir -p /opt/jetty-home \
&& tar xfz /tmp/jetty.tar.gz -C /opt/jetty-home --strip-components=1 \
&& rm /tmp/jetty.tar.gz

# Stage 2: Build WAR
FROM maven:3.9-eclipse-temurin-26 AS build
WORKDIR /workspace

COPY pom.xml .
COPY urlshortener-core/pom.xml urlshortener-core/pom.xml
COPY urlshortener-client/pom.xml urlshortener-client/pom.xml
COPY urlshortener-ui/pom.xml urlshortener-ui/pom.xml
COPY urlshortener-server/pom.xml urlshortener-server/pom.xml

RUN mvn -pl urlshortener-ui -am dependency:go-offline -q

COPY urlshortener-core/src urlshortener-core/src
COPY urlshortener-client/src urlshortener-client/src
COPY urlshortener-ui/src urlshortener-ui/src

RUN mvn -pl urlshortener-ui -am -Dmaven.test.skip=true clean package

# Stage 3: Runtime
FROM eclipse-temurin:26-jre
WORKDIR /app

COPY --from=jetty-download /opt/jetty-home /opt/jetty-home

ENV JETTY_HOME=/opt/jetty-home
ENV JETTY_BASE=/opt/jetty-base

# http-forwarded lets Jetty honor X-Forwarded-* from the ingress (TLS is
# terminated at nginx/Traefik). Without it request.isSecure() is false behind
# the proxy, so Vaadin negotiates its wss:// push connection as insecure and
# the WebSocket handshake breaks (browser: ERR_UNKNOWN_URL_SCHEME).
RUN mkdir -p ${JETTY_BASE} \
&& cd ${JETTY_BASE} \
&& java -jar ${JETTY_HOME}/start.jar \

--add-modules=http,http-forwarded,ee10-deploy,ee10-annotations,ee10-webapp

# deploy at /management so the context path matches the ingress prefix (no rewriting)
COPY --from=build /workspace/urlshortener-ui/target/ROOT.war ${JETTY_BASE}/webapps/management.war

EXPOSE 8080

WORKDIR ${JETTY_BASE}

CMD ["java", "-Djetty.http.host=0.0.0.0", "-Djetty.http.port=8080", "-jar", "/opt/jetty-home/start.jar"]

Building and pushing the images

Instead of pushing the images to a registry, we can import them directly into the MicroK8s container runtime.This keeps the VPS simple. It only needs MicroK8s and SSH access. Docker and Maven stay on your development machine. This keeps the setup simple because you do not need Docker Hub, GitHub Container Registry, or any external registry for testing.

docker build -f urlshortener-server/Dockerfile \
-t localhost:32000/urlshortener-server:latest .

docker build -f urlshortener-ui/Dockerfile \
-t localhost:32000/urlshortener-ui:latest .

The first build will take a while, Maven needs to download most of the internet, and Vaadin needs to bundle the frontend. Subsequent builds are much faster because Docker caches the dependency resolution layer separately from the source layers.

Describing the deployment with Kubernetes manifests

After building and pushing, we are still to deploy the app. We end up with just four deployment files:

  • 00-namespace.yml - The general namespace and system level configuration
  • 10-urlshortener.yml - The URL shortening service itself
  • 20-admin-ui.yml - The Vaadin based admin UI
  • 30-ingress-letsencrypt.yml - Ingress and SSL configuration for production
  • 30-ingress-local.yml - Ingress and SSL configuration for local-only deployment

As you can see there are two ingress+SSL configurations, of which only one is deployed at time.

Deploying everything

Provided you have already pushed your images and configured your domain. A small helper script k8s/deploy.sh simplifies things by executing the deployment steps in the correct sequence. Effectively it applies the Kubernetes configurations:

# The service and management UI
microk8s kubectl apply -f k8s/00-namespace.ym
microk8s kubectl apply -f k8s/10-urlshortener.yml
microk8s kubectl apply -f k8s/20-admin-ui.yml

# Depending on the deployment type one of the:
microk8s kubectl apply -f k8s/30-ingress-local.yml 
microk8s kubectl apply -f k8s/30-ingress-letsencrypt.yml

and trigger a rolling restart of the deployment pods with:

microk8s kubectl rollout restart deployment/urlshortener-server -n urlshortener
microk8s kubectl rollout restart deployment/urlshortener-ui -n urlshortener

To see that everything comes up and which pods are up and running:

microk8s kubectl rollout status deployment/urlshortener-server -n urlshortener
microk8s kubectl rollout status deployment/urlshortener-ui -n urlshortener
microk8s kubectl get pods,svc,ingress -n urlshortener

You should see both pods reach Running status within a minute or two.

TIP: There are helper scripts: build.sh, upload.sh, deploy.sh and build-and-deploy.sh that do the above.

What really changed

Since externally things look very much the same, and they should, it is worth a moment to compare what we gained and what stayed the same. Here is a summary of the main logical differences:

 

VPS

MicroK8s on VPS

Start on reboot

Manual / systemd

Automatic

SSL certificates

Certbot (interactive)

cert-manager (automatic)

Redeploy

SSH + rebuild + restart

Push image, kubectl rollout

Data persistence

File on host disk

PersistentVolume

Routing

nginx config file

Ingress manifest

 

The application code itself barely changed, the two small edits to stop hardcoding localhost would make sense anyway. Everything else lives in version-controlled YAML configuration files.

And we are ready for production. Again.

Why it is fair to argue that Kubernetes rarely makes things simpler, it is still an interesting option if your plan is to move towards a hosted Kubernetes control plane. Lots of steps still, yes, but as a learning exercise and already a CaaS compatible setup. Pairing that with a simple CI pipeline would make this a nice continuous deployment pipeline setup.

The code changes, Dockerfiles, manifests, and build script all live in the repository alongside the application code. A fresh deployment on a new UpCloud server using snap install microk8s, enable the addons, run the build scripts, and apply yaml files. These are portable, and you can move directly to a managed cluster.

Sami Ekblad
Sami Ekblad
Sami Ekblad is one of the original members of the Vaadin team. As a DX lead he is now working as a developer advocate, to help people the most out of Vaadin tools. You can find many add-ons and code samples to help you get started with Vaadin. Follow at – @samiekblad
Other posts by Sami Ekblad