DEV Community

Cover image for Deploying a Web Service on a Cloud VPS Using Kubernetes MicroK8s: A Comprehensive Guide
Liubov Pitko
Liubov Pitko

Posted on • Updated on

Deploying a Web Service on a Cloud VPS Using Kubernetes MicroK8s: A Comprehensive Guide

Introduction

As a software engineer, have you ever been excited by a fantastic idea for a web project, only to pause when faced with the overwhelming task of handling all the DevOps work needed for deployment? The mere thought of dealing with these complexities can be enough to prevent even the most eager from moving forward with their projects.

In this guide, we’re going to create a fun and simple web service that delivers random dad jokes to lighten up your day. We’ll use the Node Express.js framework to power the backend and a straightforward React application for the frontend, without a database setup. Our journey will begin with the development of the service, followed by packaging our application into a container using Docker, and finally deploying it on a cloud-based VPS through Kubernetes.

By the end of this guide, you will have a web server hosted on a cloud VPS, accessible securely via HTTPS with a fully qualified domain name, ready to spread joy with dad jokes to anyone who visits.

Result

Prerequisites

While this guide’s setup steps are described for Mac OS users, individuals using other operating systems will likely encounter a very similar process.

For this tutorial, you’ll need to ensure you have the following prerequisites:

  • Node.js: For building and running the backend server locally.
  • Docker: For containerizing the application.
  • Domain Name: You’ll need to own a domain name and have access to its DNS settings. Domain names can be purchased from providers like GoDaddy.
  • VPS Server: You will need access to a VPS server with the public IPv4 address and minimum specifications of 4GB RAM 2 CPU cores and Linux-based OS — this guide is tailored for Ubuntu 22.04, so sticking to it will ensure the best results. We’ll go over creating a VPS service below if you don’t have one yet.

Step 0. Creating VPS and updating DNS A entry

Given that DNS propagation can take several hours to complete, it is advisable to prioritize this step early on in the tutorial. Before proceeding, ensure you possess a domain name. If you need to acquire one, platforms like GoDaddy offer the option to purchase and also facilitate the modification of DNS settings.

Additionally, securing a VPS server is essential for the next steps. For this guide, I will be utilizing VDSina.com due to its flexible payment system, which allows for daily rather than monthly payments — a convenient feature for this tutorial.

Please note that you are free to choose any VPS provider that suits your needs.

Creating VPS with VDSina

First, let’s generate an SSH key — we’ll be using it to access our server. Run ssh-keygen in the terminal and click through (you can use all defaults).

Create an account on VDSina.com and add a new SSH key — paste the value from id_rsa.pub file.

Add SSH Key

Now, let’s create our server with Ubuntu 22.04. Note that you’ll need to have a couple of bucks on your balance to purchase a server, which should only cost about 0,33$ / day.

Image description

After about 2–5 minutes server should be created and we can start using it.

Image description

For this step, we only need to copy the IP address of the server and update our DNS entries for our domain to point to that IP.

Updating DNS A record

Now, navigate to the DNS settings of your domain to add a new A record. The entry should be configured with the following values:

Name/Host: dadjokes
Type: A
Value: IP address for your server, e.g. 80.85.245.188 in my case

Image description

While I am using the DNS settings page provided by GoDaddy, as my domain was also purchased there, you should access the DNS settings through the provider you’ve used to acquire your domain.

Once you’ve made the changes, the DNS propagation process will start. It’s important to remember that updating DNS records can take anywhere from 20 minutes to 72 hours, so patience is key during this period. To monitor the status of the propagation, you can use the online tool available at https://www.whatsmydns.net/#A, which provides real-time updates on how your DNS changes are spreading across the internet.

Step 1. Building a Web Service

In this chapter, we will develop a simple yet engaging web service that delivers random dad jokes to lighten up your day. The service architecture includes a backend powered by the Node.js Express framework with Typescript, coupled with a frontend developed as a single-page application (SPA) utilizing React.

Let’s get started!

Building a backend server

Create a new project folder named dadjokes with backend subfolder. Navigate inside backend directory and create a new package.json file with the next content:

{
  "name": "dadjokes-backend",
  "version": "1.0.0",
  "description": "Dadjokes Generator Service",
  "main": "server.js",
  "scripts": {
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that you can also initiate npm project by running npm init in the terminal.

Now, let’s install the all required dependencies for our typescript backend service:

npm i express
npm i -D typescript nodemon ts-node @types/node @types/express
Enter fullscreen mode Exit fullscreen mode

This will add new entries to the package.json file, create a package-lock.json file and node_modules directory with installed modules.

To configure the TypeScript compiler for your project, we’ll add a tsconfig.json file to the root directory with the options specified below:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "rootDir": "./",
    "outDir": "./dist",
    "esModuleInterop": true,
    "strict": true
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note that we’ll not go over the options in this tutorial, you can read about these options on the official typescript site.

Moving forward, let’s add a server with the logic for retrieving a random dad joke. Please note that to keep things simple, we’re not going to use the database, but instead use a fixed list of jokes stored as an array.

Create src folder and two new files under it: jokes.ts and server.ts. The file structure at this point should look like below:

dadjokes
 - backend
   - node_modules (generated by npm)
   - src
     - jokes.ts
     - server.ts
   - package.json
   - package-lock.json (generated by npm)
   - tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Add the code with logic to retrieve a random joke to the jokes.ts:

const JOKES = [
    {id: 1, setup: 'I hate people who talk about me behind my back...', punchline: 'They discussed me.'},
    {id: 2, setup: "Ladies, if he can't appreciate your fruit jokes...", punchline: "...you need to let that mango"},
    {id: 3, setup: "Did you hear about the 2 guys that stole a calendar?", punchline: "They both got 6 months."},
    {id: 4, setup: "I've got a great joke about construction,", punchline: "but I'm still working on it."},
    {id: 5, setup: "I ordered a chicken and an egg online.", punchline: "I’ll let you know."},
    {id: 6, setup: "If you see a crime at an Apple Store,", punchline: "does that make you an iWitness?"},
    {id: 7, setup: "I'm so good at sleeping,", punchline: "I can do it with my eyes closed!"},
    {id: 8, setup: "I was going to tell a time-traveling joke,", punchline: "but you guys didn't like it."},
    {id: 9, setup: "How do lawyers say goodbye?", punchline: "We'll be suing ya!"},
    {id: 10, setup: "Spring is here!", punchline: "I got so excited I wet my plants."},
    {id: 11, setup: "Don't trust atoms.", punchline: "They make up everything!"},
    {id: 12, setup: "When does a joke become a dad joke?", punchline: "When it becomes apparent."},
    {id: 13, setup: "What did the fish say when he hit the wall?", punchline: "Dam."},
    {id: 14, setup: "Is this pool safe for diving?", punchline: "It deep ends."},
    {id: 15, setup: "I once got fired from a canned juice factory.", punchline: "Apparently I couldn't concentrate."},
    {id: 16, setup: "A cheese burger walks into a bar, the bartender says", punchline: "sorry sir we don't serve food here."},
    {id: 17, setup: "When I was a kid, my mother told me I could be anyone I wanted to be.", punchline: "Turns out, identity theft is a crime."},
    {id: 18, setup: "Did you hear about the restaurant on the moon?", punchline: "Great food, no atmosphere!"},
    {id: 19, setup: "Why did the old man fall in the well?", punchline: "Because he couldn't see that well!"},
    {id: 20, setup: "Why did the invisible man turn down the job offer?", punchline: "He couldn't see himself doing it!"},
    {id: 21, setup: "Within minutes, the detectives knew what the murder weapon was.", punchline: "It was a brief case."},
    {id: 22, setup: "To whoever stole my copy of Microsoft Office, I will find you.", punchline: "You have my Word!"},
    {id: 23, setup: "I thought about going on an all-almond diet...", punchline: "But that's just nuts!"},
    {id: 24, setup: "I told my girlfriend she drew her eyebrows too high.", punchline: "She seemed surprised!"},
    {id: 25, setup: "I know a lot of jokes about retired people", punchline: "but none of them work!"},
]

let seenJokes: number[] = [];

export function getRandomJoke() {
    if (seenJokes.length === JOKES.length) {
        seenJokes = [];
    }
    const unseenJokes = JOKES.filter(joke => seenJokes.indexOf(joke.id) === -1);
    const randomIndex = Math.floor(Math.random() * unseenJokes.length);
    const selectedJoke = unseenJokes[randomIndex];
    seenJokes.push(selectedJoke.id);
    return selectedJoke;
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s add a server logic to the server.ts file:

import express, {Request, Response} from "express"
import {getRandomJoke} from "./jokes";

const app = express()
const PORT = process.env.PORT || 8000

app.get('/api/joke', (req: Request, res: Response) => {
    res.send(getRandomJoke())
})

app.get('/', (req: Request, res: Response) => {
    res.send('Hello there!')
})

app.listen(PORT, () => console.log(`Server Running on Port ${PORT}`))
Enter fullscreen mode Exit fullscreen mode

Update package.json scripts section with dev script to run a server locally:

"scripts": {
    "dev": "nodemon src/server.ts"
}
Enter fullscreen mode Exit fullscreen mode

Next, run npm run dev from the terminal and open the browser with http://localhost:8000/ — you will see “Hello there!” returned from the server.

Change the URL to http://localhost:8000/api/joke and reload the page a couple of times — you will see a new joke returned on every reload.

Image description

We’re done with the backend service for now, let’s switch to the client.

(!) Don’t kill the server process just yet, we’ll need it to test our web service locally, so use the new terminal tab for the frontend.

Building a front-end client

For the front-end single-page application, we’ll be using React. To build it we’ll use vite.js — a modern frontend build tool that significantly improves the development experience for web developers.

Switch to the dadjokes folder in the terminal and run:

npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

This will bring a dialog with questions about the future project — provide the following answers:

% npm create vite@latest
npx: installed 1 in 1.441s
✔ Project name: … frontend
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in /Users/liubovpitko/git/dadjokes/frontend...

Done. Now run:

  cd frontend
  npm install
  npm run dev
Enter fullscreen mode Exit fullscreen mode

Run the suggested commands:

cd frontend
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

You should see the following in the terminal:

% npm run dev

> frontend@0.0.0 dev
> vite

  VITE v5.1.1  ready in 605 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help
Enter fullscreen mode Exit fullscreen mode

If you open the browser on http://localhost:5173/ you’ll see the default Vite template project showing up:

Image description

Let’s introduce a custom logic to our application. We’ll have a button that fetches the random joke from the server and displays it on the screen with a smooth appearance animation.

Replace the code in the src/App.tsx to the following:

import {useState} from 'react'
import './App.css'

interface Joke {
    id: number;
    setup: string;
    punchline: string;
}

function App() {
    const [joke, setJoke] = useState<Joke>();

    const getNewJoke = async () => {
        const newJoke: Joke = await fetch('/api/joke').then(response => response.json());
        setJoke(newJoke)
    }

    return (
        <div>
            {joke ?
                <div key={joke.id}>
                    <div className="joke">
                        {joke.setup}
                    </div>
                    <div className="joke" style={{'animationDelay': `${500 + joke?.setup.length * 30}ms`}}>
                        {joke.punchline}
                    </div>
                    <button onClick={getNewJoke}>
                        Get another one
                    </button>
                </div> :
                <button onClick={getNewJoke}>
                    Get a joke
                </button>
            }
        </div>
    )
}

export default App
Enter fullscreen mode Exit fullscreen mode

You can notice that we’re fetching the joke without defining a host and a port but using a relative path to /api/joke on this line:

const newJoke: Joke = await fetch('/api/joke').then(response => response.json());
Enter fullscreen mode Exit fullscreen mode

This setup is suitable for production, where both the backend and frontend share the same URL. However, for local development, adjustments are needed. Without changes, requests from the frontend development server will be sent to http://localhost:5173 and won't reach the local backend server at http://localhost:8000. To fix this, we need to set up a proxy for the local development server.

Open vite.config.ts and replace the code with the following:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
        secure: false
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

We’re almost ready to test our app, but first, let’s replace definitions in src/index.css to make it just a little bit prettier:

body {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  color: #213547;
  display: flex;
  place-items: center;
  min-height: 100vh;
}

button {
  border-radius: 0.5rem;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 2rem;
  font-weight: 500;
  font-family: inherit;
  cursor: pointer;
  transition: border-color 0.25s;
  background-color: #f9f9f9;
}
button:hover {
  border-color: #213547;
}
button:focus,
button:focus-visible {
  outline: 4px auto #1e262f;
}

@keyframes appear {
  from {opacity: 0;}
  to {opacity: 1;}
}

.joke {
  font-size: 2rem;
  margin-bottom: 2rem;
  opacity: 0;
  animation: appear 2s forwards;
}
Enter fullscreen mode Exit fullscreen mode

And update index.html title to:

<title>Dad Jokes Generator</title>
Enter fullscreen mode Exit fullscreen mode

Open the browser and check what we have:

Image description

Great! We’re nearly ready to dive into deployment. But first, we need to modify our backend service to serve static frontend files to wrap our application in a docker image.

Modifying the backend to serve static files

Let’s prepare our backend to serve static files from the frontend app.

Replace next lines:

app.get('/', (req: Request, res: Response) => {
    res.send('Hello there!')
})
Enter fullscreen mode Exit fullscreen mode

with the following code:

const cwd = process.cwd()
app.use(express.static(`${cwd}/static`));
app.use((req: Request, res: Response) => {
    res.sendFile(`${cwd}/static/index.html`, (err) => {
        if (err) {
            res.status(500).send(err);
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

The server.ts code should look like the following:

import express, {Request, Response} from "express"
import {getRandomJoke} from "./jokes";

const app = express()
const PORT = process.env.PORT || 8000

app.get('/api/joke', (req: Request, res: Response) => {
    res.send(getRandomJoke())
})

const cwd = process.cwd()
app.use(express.static(`${cwd}/static`));
app.use((req: Request, res: Response) => {
    res.sendFile(`${cwd}/static/index.html`, (err) => {
        if (err) {
            res.status(500).send(err);
        }
    });
});

app.listen(PORT, () => console.log(`Server Running on Port ${PORT}`))
Enter fullscreen mode Exit fullscreen mode

This code enables access to static files stored in the backend/static folder, making them available to the browser. For paths not specified, we will default to serving the index.html file, which presents the frontend interface and manages the underlying logic.

It's important to note that since we haven't yet placed any static files in the static folder, attempting to visit http://localhost:8000/ at this stage will result in an error.

Now we’re ready to containerize our application and prepare it for deployment.

Step 2. Containerizing application

Containerizing an app means wrapping up everything it needs to run — like its code, necessary software, and settings — into a single package called a container image. This makes the app run the same way, no matter where you use it, on any system that can handle containers, such as Docker.

In this guide, we’re using Docker containers because we’ll be deploying our app with Kubernetes. Think of Kubernetes as a manager for containers — it helps us launch and run our apps smoothly and in a consistent way, no matter where they are.

If you’re new to Docker and Kubernetes and want to dig deeper, I suggest checking out TechWorld with Nana on YouTube. It’s a great resource to learn about the latest in DevOps tools and technologies in an easy-to-understand way.

Creating a Dockerfile

Dockerfile — is a text document that contains instructions to assemble a docker image.

Let’s create a file named Dockerfile in the root of the dadjokes project and start with the next line:

FROM --platform=linux/x86_64 node:20.11-alpine
Enter fullscreen mode Exit fullscreen mode

This instructs docker to start building our image from an existing node image based on Alpine Linux. Alpine distribution is the smallest Linux distribution which allows building lightweight images.

Now, let's instruct Docker to install typescript globally as we need to build our apps.

RUN npm install -g typescript
Enter fullscreen mode Exit fullscreen mode

Next, we need to copy our project files into the container and build the backend app with typescript.

WORKDIR /backend
COPY ./backend .
RUN tsc
Enter fullscreen mode Exit fullscreen mode

We’re using WORKDIR instruction to create a new directory and change the dir to it inside the container. We then use COPY command to copy all files that we have in our local ./backend directory to /backend directory inside the container. Lastly, we build our backend application with RUN tsc command — this will create a /backend/dist folder with built files.

We need to install production dependencies for our app inside the container. To do that, we’ll copy package.json and package-lock.json to the directory with built files and run the npm command to install only production dependencies.

COPY ./backend/package*.json ./dist
RUN cd /backend/dist && npm install --production
Enter fullscreen mode Exit fullscreen mode

Now, we also need to build and copy frontend static files to our backend.

WORKDIR /frontend
COPY ./frontend .
RUN npm i && npm run build && cp -R ./dist /backend/dist/static
Enter fullscreen mode Exit fullscreen mode

In the second stage of our multi-stage Dockerfile, we will selectively copy only the essential files needed to run our application from the first stage into a new container, leaving behind any files that were solely necessary for the development and building of the app. This approach ensures that our image is both leaner and more efficient.

All files we need for our app are now stored in the /backend/dist folder of the app, therefore we’ll copy it over with COPY --from=0 command where --from=0 instructs to copy from the 0’s stage of the current Dockerfile.

FROM --platform=linux/x86_64 node:20.11-alpine
COPY --from=0 /backend/dist /app
Enter fullscreen mode Exit fullscreen mode

Finally, we’ll instruct our image to run our server:

WORKDIR /app
CMD ["src/server.js"]
Enter fullscreen mode Exit fullscreen mode

The final Dockerifle will look like shown below:

FROM --platform=linux/x86_64 node:20.11-alpine

RUN npm install -g typescript

WORKDIR /backend
COPY ./backend .
RUN tsc

COPY ./backend/package*.json ./dist
RUN cd /backend/dist && npm install --production

WORKDIR /frontend
COPY ./frontend .
RUN npm i && npm run build && cp -R ./dist /backend/dist/static

FROM --platform=linux/x86_64 node:20.11-alpine
COPY --from=0 /backend/dist /app

WORKDIR /app
CMD ["src/server.js"]
Enter fullscreen mode Exit fullscreen mode

Perfect! Now let’s return to the terminal and build the image:

docker build . -t dadjokes
Enter fullscreen mode Exit fullscreen mode

And run it:

docker run -p 8000:8000 dadjokes
Enter fullscreen mode Exit fullscreen mode

Now, if you open the browser on http://localhost:8000/ you should see our app the same way you saw it at the end of the previous step.

Note that if you are still running the development server on port 8000 you will get an error:

docker: Error response from daemon: Ports are not available: listen tcp 0.0.0.0:8000: bind: address already in use.
Enter fullscreen mode Exit fullscreen mode

To fix it either kill the development server or use a different port to run our app on the host by changing this part of the docker run command -p 8001:8000 and accessing it on the new port, e.g. http://localhost:8001/

Awesome job! We are now ready for the most exciting part — deployment of the application to the VPS.

Step 3. Setting up Kubernetes cluster and docker registry

In this section, we will work on installing microk8s on our VPS server, setting up access to it from our local machine, and creating a docker registry to use for deployment.

Setting up Kubernetes cluster

Let’s SSH into your VPS server using an IP address from step 0:

ssh root@80.85.245.188 
Enter fullscreen mode Exit fullscreen mode

Upgrade packages for Ubuntu first:

apt-get update
apt-get upgrade
Enter fullscreen mode Exit fullscreen mode

And install microk8s:

sudo snap install microk8s --classic
Enter fullscreen mode Exit fullscreen mode

Check that microk8s installed and running:

microk8s status

microk8s is running
high-availability: no
  datastore master nodes: 127.0.0.1:19001
  datastore standby nodes: none
...
Enter fullscreen mode Exit fullscreen mode

Enable the following add-ons:

microk8s enable ingress
microk8s enable registry
microk8s enable cert-manager
Enter fullscreen mode Exit fullscreen mode

Now we’ll need to enable pulling images from the local registry. The latest instructions about the private registry can be found on the microk8s site.

Create a new configuration directory and file for the registry. Ensure to replace 80.85.245.188 with your IP.

sudo mkdir -p /var/snap/microk8s/current/args/certs.d/80.85.245.188:32000
Enter fullscreen mode Exit fullscreen mode

Now, create the file hosts.toml with the next content. Ensure to replace 80.85.245.188 with your IP:

vi /var/snap/microk8s/current/args/certs.d/80.85.245.188:32000/hosts.toml
Enter fullscreen mode Exit fullscreen mode
server = "http://80.85.245.188:32000"

[host."http://80.85.245.188:32000"]
capabilities = ["pull", "resolve"]

Enter fullscreen mode Exit fullscreen mode

And restart microk8s:

microk8s stop
microk8s start
Enter fullscreen mode Exit fullscreen mode

Ensure microk8s is running:

microk8s status
Enter fullscreen mode Exit fullscreen mode

Great. Microk8s is installed and configured and now we’ll work on connecting to it from a local machine. Run:

microk8s config > kubeconfig
Enter fullscreen mode Exit fullscreen mode

Now let's return to your local machine terminal and copy the created kubeconfig. Replace the IP with your server’s address.

export IP=80.85.245.188
mkdir -p ~/.kube/microk8s/
scp root@$IP:/root/kubeconfig ~/.kube/microk8s/
Enter fullscreen mode Exit fullscreen mode

For the next step we will need kubectl — a command-line tool to work with Kubernetes, please install it.

We should be able to access the cluster now, let’s run:

export KUBECONFIG=~/.kube/microk8s/kubeconfig
kubectl get namespaces
Enter fullscreen mode Exit fullscreen mode

You should see something like below if everything works correctly:

kubectl get namespaces
NAME                 STATUS   AGE
kube-system          Active   5m30s
kube-public          Active   5m30s
kube-node-lease      Active   5m30s
default              Active   5m29s
ingress              Active   4m27s
container-registry   Active   4m18s
cert-manager         Active   4m14s
Enter fullscreen mode Exit fullscreen mode

Setting up the Docker registry

To utilize Docker images we’ve built locally, we must store them in a registry from which Kubernetes can retrieve them. One common choice is the official DockerHub registry, which provides free public repositories. However, for private repositories, which might be more suitable for your production application, DockerHub charges around $5/month. This is a convenient option that requires no extra setup beyond the cost.

Nonetheless, for this guide, we’ll opt for setting up our own private Docker registry. This option is straightforward and free, offering a cost-effective solution for storing images. It’s important to note, though, that this self-hosted registry may not be as fast or efficient as paid services.

Earlier in the guide, we enabled a registry add-on within our microk8s cluster, which we’ll use to store our images. This registry is hosted directly within the Kubernetes cluster and is accessible via port 32000.

To utilize this registry, we need to construct an image and tag it with the registry’s address as part of the image name. Navigate to the root directory of the dadjokes project and execute the docker build command in the format shown below. Remember to substitute IP with the actual IP address of your VPS:

docker build . -t 80.85.245.188:32000/dadjokes:latest
Enter fullscreen mode Exit fullscreen mode

Let’s try pushing it to the registry:

docker push 80.85.245.188:32000/dadjokes:latest
Enter fullscreen mode Exit fullscreen mode

You should see a similar message:

The push refers to repository [80.85.245.188:32000/dadjokes]
Get "https://80.85.245.188:32000/v2/": http: server gave HTTP response to HTTPS client
Enter fullscreen mode Exit fullscreen mode

The reason for this error is that our registry operates over HTTP and lacks encryption, making it unsecured. Nonetheless, since we are the creators of the registry, we can choose to trust it. To configure Docker to recognize and trust our registry, navigate to Docker Desktop, then proceed to Preferences → Docker Engine. Within the provided text field, you should add the following configuration:

"insecure-registries": [
  "<IP>:32000"
],
Enter fullscreen mode Exit fullscreen mode

Image description

Click on “Apply & Restart” to apply the new docker configuration.

Now we can push our docker image again:

docker push 80.85.245.188:32000/dadjokes
Enter fullscreen mode Exit fullscreen mode

This time everything should work and the image will be pushed to our server — this may take a couple of minutes.

Fantastic! We’re so close to running our app on the internet!

Step 4. Creating Kubernetes Configs and Deploying App

Kubernetes orchestrates deployments and manages resources through yaml configuration files. While Kubernetes supports a wide array of resources and configurations, our aim in this tutorial is to maintain simplicity. For the sake of clarity and ease of understanding, we will use yaml configurations with hardcoded values. This method simplifies the learning process but isn’t ideal for production environments due to the need for manual updates with each new deployment. Although there are methods to streamline and automate this process, such as using Helm charts or bash scripts, we’ll not delve into those techniques to keep the tutorial manageable and avoid fatigue — you might be quite tired by that point!

Let’s create a deployment folder in the root of the dadjokes project — we will be creating configurations in it one by one and deploying them to our cluster.

First, let’s create a new namespace in Kubernetes for our application and switch context to it:

export KUBECONFIG=~/.kube/microk8s/kubeconfig
kubectl create namespace dadjokes
kubectl config set-context --current --namespace=dadjokes
Enter fullscreen mode Exit fullscreen mode

Let’s create a service-account.yaml to define a service account that Kubernetes will be using to deploy our app:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: dadjokes-service-account
  namespace: dadjokes
Enter fullscreen mode Exit fullscreen mode

Create a deployment.yaml to define a Deployment resource. A Deployment in Kubernetes is a resource that manages and updates a group of identical pods that run our application. Ensure to substitute IP 80.85.245.188 to your VPS IP.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: dadjokes-deployment
  namespace: dadjokes
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: dadjokes
  template:
    metadata:
      labels:
        app.kubernetes.io/name: dadjokes
    spec:
      serviceAccountName: dadjokes-service-account
      containers:
        - name: dadjokes
          image: 80.85.245.188:32000/dadjokes:latest
          imagePullPolicy: Always
          ports:
            - name: http
              containerPort: 8000
              protocol: TCP
Enter fullscreen mode Exit fullscreen mode

Next, create a service.yaml to define a Service resource that exposes our deployment to the internal network:

apiVersion: v1
kind: Service
metadata:
  name: dadjokes-service
  namespace: dadjokes
spec:
  type: ClusterIP
  ports:
    - port: 8000
      targetPort: http
      protocol: TCP
      name: http
  selector:
    app.kubernetes.io/name: dadjokes
Enter fullscreen mode Exit fullscreen mode

Now, create a cluster-issuer.yaml to define a ClusterIssuer resource that acts like certificate authorities that can generate signed certificates by honoring certificate signing requests. Replace your.email@gmail.com with your email:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: dadjokes-letsencrypt
  namespace: dadjokes
spec:
  acme:
    email: your.email@gmail.com
    privateKeySecretRef:
      name: letsencrypt-private-key
    server: https://acme-v02.api.letsencrypt.org/directory
    solvers:
      - http01:
          ingress:
            class: public
        selector: {}
Enter fullscreen mode Exit fullscreen mode

Create certificate.yaml to define a Certificate for our site. Replace dadjokes.your-domain.xyz with your domain:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: dadjokes-certificate
  namespace: dadjokes
spec:
  secretName: tls-dadjokes
  duration: 24h
  renewBefore: 12h
  commonName: dadjokes.your-domain.xyz
  dnsNames:
    - dadjokes.your-domain.xyz
  issuerRef:
    name: dadjokes-letsencrypt
    kind: ClusterIssuer
Enter fullscreen mode Exit fullscreen mode

And lastly, let's create ingress.yaml to expose our application to the internet and also specify which certificate to use. Replace dadjokes.your-domain.xyz with your domain:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: dadjokes-ingress
  namespace: dadjokes
  annotations:
    cert-manager.io/cluster-issuer: dadjokes-letsencrypt
spec:
  ingressClassName: nginx
  rules:
    - host: dadjokes.your-domain.xyz
      http:
        paths:
          - backend:
              service:
                name: dadjokes-service
                port:
                  number: 8000
            path: /
            pathType: Prefix
  tls:
    - hosts:
        - dadjokes.your-domain.xyz
      secretName: tls-dadjokes
Enter fullscreen mode Exit fullscreen mode

We’re ready to apply all our configurations and access our service. Run:

kubectl apply -f service-account.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f cluster-issuer.yaml
kubectl apply -f certificate.yaml
kubectl apply -f ingress.yaml
Enter fullscreen mode Exit fullscreen mode

After several seconds/minutes, check that pods are running and certificates are ready:

kubectl get pods,certificate

NAME                                       READY   STATUS    RESTARTS   AGE
pod/dadjokes-deployment-6d9ff9d4ff-dvhpq   1/1     Running   0          34s

NAME                                               READY   SECRET         AGE
certificate.cert-manager.io/tls-dadjokes           True    tls-dadjokes   30s
certificate.cert-manager.io/dadjokes-certificate   True    tls-dadjokes   31s
Enter fullscreen mode Exit fullscreen mode

After everything is applied you should be able to access our service at your domain using https.

Image description

If you’re unable to access the site, DNS propagation may be still in progress. To monitor the status, visit https://www.whatsmydns.net/#A. Once you observe that the majority of the locations are displaying your IP address, the site should become accessible.

Deploying a new version of an application

To deploy an updated version of your service, please follow the below steps.

Build and push a new docker image from dadjokes directory with a new version tag, e.g. v2:

docker build . -t 80.85.245.188:32000/dadjokes:v2
docker push 80.85.245.188:32000/dadjokes:v2
Enter fullscreen mode Exit fullscreen mode

Update deployments.yaml to use a new image tag:

...
  containers:
    - name: dadjokes
      image: 80.85.245.188:32000/dadjokes:v2
...
Enter fullscreen mode Exit fullscreen mode

Apply new configuration:

kubectl apply -f deployment.yaml
Enter fullscreen mode Exit fullscreen mode

Closing thoughts

The process described in this tutorial unlocks plenty of opportunities for web development. With your own VPS server and Kubernetes, you can create all sorts of different applications and have full control over them. This guide illustrates one of the simple methods for deploying your application using quite advanced technologies like Docker and Kubernetes. Once the initial setup is complete, updating your service with new features and deploying them directly from your local machine becomes straightforward as described above. Kubernetes serves as an excellent resource for managing your application deployments and facilitating the construction of complex applications through code-driven configurations.

I hope this article was useful! Happy coding and deployments!

Top comments (0)