Deploying a Web Service on a Cloud VPS Using Kubernetes MicroK8s: A Comprehensive Guide
Tags: kubernetes, docker, beginners, website
Published: 2024-02-20 04:22:57

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u9sdobv3u6ldh46k6497.gif)

## 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](https://nodejs.org/en/download): For building and running the backend server locally.
- [Docker](https://www.docker.com/get-started/): 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](https://www.godaddy.com/domainsearch/find) 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](https://www.vdsina.com/?partner=frzcsx5p13) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/abq1uuwur0q33369k28g.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mk6cgge7j8oftdprgbcx.png)

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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ye78ie43r9zl739n5jvr.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gihgthkaf0qm38sg6i3t.png)

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](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:

```json
{
  "name": "dadjokes-backend",
  "version": "1.0.0",
  "description": "Dadjokes Generator Service",
  "main": "server.js",
  "scripts": {
  }
}
```

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:

```bash
npm i express
npm i -D typescript nodemon ts-node @types/node @types/express
```

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:

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

Note that we’ll not go over the options in this tutorial, you can read about these options on the [official typescript site](https://www.typescriptlang.org/tsconfig).

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
```

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

```typescript
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;
}
```

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

```typescript
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}`))
```

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

```json
"scripts": {
    "dev": "nodemon src/server.ts"
}
```

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

Change the URL to [http://localhost:8000/api/joke](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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mb8ms2e2gnzs10q76gbr.gif)

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](https://vitejs.dev/) — a modern frontend build tool that significantly improves the development experience for web developers.

Switch to the `dadjokes` folder in the terminal and run:

```bash
npm create vite@latest
```

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

```bash
% 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
```

Run the suggested commands:

```bash
cd frontend
npm install
npm run dev
```

You should see the following in the terminal:

```bash
% 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
```

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


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iw69olw1ee7nvp1e09w7.png)

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:

```typescript
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
```

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:

```typescript
const newJoke: Joke = await fetch('/api/joke').then(response => response.json());
```

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:

```typescript
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
      }
    }
  }
})
```

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:

```css
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;
}
```

And update `index.html` title to:

```html
<title>Dad Jokes Generator</title>
```

Open the browser and check what we have:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/49t7qmix6z4l3byfk6ah.gif)

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:

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

with the following code:

```typescript
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);
        }
    });
});
```

The `server.ts` code should look like the following:

```typescript
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}`))
```

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](https://www.youtube.com/@TechWorldwithNana). 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
```
This instructs docker to start building our image from an [existing](https://hub.docker.com/_/node/) 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
```

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

```
WORKDIR /backend
COPY ./backend .
RUN tsc
```

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
```

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
```

In the second stage of our [multi-stage](https://docs.docker.com/build/building/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
```

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

```
WORKDIR /app
CMD ["src/server.js"]
```

The final Dockerifle will look like shown below:

```Dockerfile
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"]
```

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

```
docker build . -t dadjokes
```

And run it:

```
docker run -p 8000:8000 dadjokes
```

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.
```

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 
```

Upgrade packages for Ubuntu first:

```
apt-get update
apt-get upgrade
```

And install [microk8s](https://microk8s.io/):

```
sudo snap install microk8s --classic
```

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
...
```

Enable the following add-ons:

```
microk8s enable ingress
microk8s enable registry
microk8s enable cert-manager
```

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](https://microk8s.io/docs/registry-private) 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
```

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
```

```
server = "http://80.85.245.188:32000"

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

```

And restart microk8s:

```
microk8s stop
microk8s start
```

Ensure microk8s is running:

```
microk8s status
```

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

```
microk8s config > kubeconfig
```

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/
```

For the next step we will need `kubectl` — a command-line tool to work with Kubernetes, please [install](https://kubernetes.io/docs/tasks/tools/install-kubectl-macos/) it.

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

```
export KUBECONFIG=~/.kube/microk8s/kubeconfig
kubectl get namespaces
```

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
```

### 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](https://www.docker.com/pricing/) 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
```

Let’s try pushing it to the registry:

```
docker push 80.85.245.188:32000/dadjokes:latest
```

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
```

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"
],
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sk6g2ean0t4057wckin5.png)

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
```

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](https://helm.sh/) 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
```

Let’s create a `service-account.yaml` to define a [service account](https://kubernetes.io/docs/concepts/security/service-accounts/) that Kubernetes will be using to deploy our app:

```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: dadjokes-service-account
  namespace: dadjokes
```

Create a `deployment.yaml` to define a [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) resource. A Deployment in Kubernetes is a resource that manages and updates a group of identical [pods](https://kubernetes.io/docs/concepts/workloads/pods/) that run our application. Ensure to substitute IP `80.85.245.188` to your VPS IP.

```yaml
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
```

Next, create a `service.yaml` to define a [Service](https://kubernetes.io/docs/concepts/services-networking/service/) resource that exposes our deployment to the internal network:

```yaml
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
```

Now, create a `cluster-issuer.yaml` to define a [ClusterIssuer](https://cert-manager.io/docs/concepts/issuer/) resource that acts like certificate authorities that can generate signed certificates by honoring certificate signing requests. Replace `your.email@gmail.com` with your email:

```yaml
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: {}
```

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

```yaml
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
```

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:

```yaml
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
```

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

```bash
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
```

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
```

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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tdz9y4ft6uj5rgqiti8w.png)

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](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
```

Update `deployments.yaml` to use a new image tag:

```yaml
...
  containers:
    - name: dadjokes
      image: 80.85.245.188:32000/dadjokes:v2
...
```

Apply new configuration:

```
kubectl apply -f deployment.yaml
```

## 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!