# Deploying a Python Web App with on Kubernetes and Persistent NFS Storage

### You’ll learn how to :

### \- Build a simple Flask web app that Displays an image on a web page and allows users to upload and replace the image

### \- Containerizing it using Docker

### \- Pushing the image to a Docker hub registry

### \- Deploying it in Kubernetes - Create Kubernetes secrets, deployments, service, init container

### \- Setting up persistent volumes with an NFS volume using static PVs/PVCs

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1748487105155/e6b35dba-3dc7-4462-87aa-5ac29d33cde0.png align="left")

### Folder Structure

Below is a typical folder structure for this project.

This structure separates the application logic, HTML templates, and static assets, making it easy to maintain and deploy.

```bash
my_app/
├── app.py # Flask application code
├── Dockerfile # Dockerfile for containerization
├── requirements.txt # (Optional) Python dependencies
├── templates/
│ └── index.html # HTML template for the web app
└── static/
├── uploads/ # Directory to store uploaded images (persistent)
└── images/
└── logo.png # logo used in the header
```

### 1\. Setting Up Your Development Environment

#### Installing Python and Flask

Before starting, ensure you have **Python 3** installed. You can install it using:

```bash
sudo apt update && sudo apt install python3 python3-pip -y # Ubuntu/Debian
```

```bash
brew install python3 #MacOS
```

For Windows, download and install Python from [python.org](https://www.python.org/downloads/).

Next, install Flask:

```bash
pip3 install flask
```

### 2\. Building the Flask App

### Flask is a lightweight and flexible Python web framework used for building web applications quickly. It is minimalist yet powerful.

**How It Works:**

* The app initially displays `static/uploads/current.jpg`.
    
* Users can upload an image via a form.
    
* The uploaded image replaces the old one.
    

Lets add the code to the app.py for the application & the index.html for the web-interface

* ### ***app.py***
    

```python
from flask import Flask, render_template, request, redirect, url_for
import os

app = Flask(__name__)
UPLOAD_FOLDER = 'static/uploads/'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

# Ensure the upload folder exists
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

# Default image
DEFAULT_IMAGE = 'default.jpg'
image_path = os.path.join(UPLOAD_FOLDER, DEFAULT_IMAGE).replace("\", "/")

@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
if "file" not in request.files:
return redirect(request.url)

file = request.files["file"]
if file.filename == "":
return redirect(request.url)

if file:
filepath = os.path.join(app.config['UPLOAD_FOLDER'], "current.jpg").replace("\", "/")
file.save(filepath)

return render_template("index.html", image_url="static/uploads/current.jpg")

#if __name__ == "__main__":
# app.run(debug=True)

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
```

* *templates/index.html*
    

```xml
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Image Upload</title>
  <style>
    /* Set the background to light blue */
    body {
      background-color: lightblue;
      margin: 0;
      font-family: Arial, sans-serif;
    }
    /* Header for the Kubernetes logo at the top left */
    header {
      position: fixed;
      top: 0;
      left: 0;
      padding: 10px;
      z-index: 1000; /* Ensure header stays on top */
    }
    header img {
      height: 70px; /* Adjust size as needed */
    }
    /* Container for main content with padding to avoid header overlap */
    .content {
      padding-top: 50px;
    }
    /* Style for the green upload button */
    .upload-button {
      background-color: green;
      border: none;
      color: rgb(213, 213, 213);
      padding: 10px 20px;
      text-align: center;
      text-decoration: none;
      display: inline-block;
      font-size: 16px;
      margin: 4px 2px;
      cursor: pointer;
      border-radius: 4px;
    }
    /* Style to center and enlarge the image */
    .centered-image {
      display: block;
      margin: 20px auto;
      max-width: 80%;
      height: auto;
    }
    /* Center-align headings and form */
    .center {
      text-align: center;
    }
  </style>
</head>
<body>
  <header>
    <!-- Update the src to point to your logo file -->
    <img src="static/images/logo.png" alt="Logo">
  </header>
  <div class="content">
    <h2 class="center">Upload an Image</h2>
    <form method="POST" enctype="multipart/form-data" class="center">
      <input type="file" name="file">
      <button type="submit" class="upload-button">Upload</button>
    </form>
    <h3 class="center">Current Image:</h3>
    <img src="{{ image_url }}" alt="Uploaded Image" class="centered-image">
  </div>
</body>
</html>
```

## Running the App on your local system

```python
python app.py
```

Visit `http://127.0.0.1:5000/` in your browser to access the app.

# **3\. Containerizing with Docker**

To install docker on your system, you can follow this official [***docker installation guide***](https://docs.docker.com/engine/install/ubuntu)

Lets create the docker file

* *Dockerfile*
    

```yaml
# Use the official Python image as a base
FROM python:3.9

# Set the working directory
WORKDIR /app

# Copy all files to the container
COPY . .

# Install dependencies
RUN pip install flask

# Expose the port Flask runs on
EXPOSE 5000

# Run the application
CMD ["python", "app.py"]
```

Building & Running the Docker Container

* Build the Docker Image:
    

```bash
docker build -t my_app .
```

* Run the Container in Detached Mode:
    

```bash
docker run -d -p 5000:5000 -v $(pwd)/static/uploads:/app/static/uploads --name mywebapp my_app
```

Explanation of Flags:

* `-d` → Runs the container in **detached mode** (background).
    
* `-p 5000:5000` → Maps **port 5000** of the container to **port 5000** on the node.
    
* `-v $(pwd)/static/uploads:/app/static/uploads` → Mounts the upload directory so files persist.
    
* `--name mywebapp`→ Assigns the container a custom name (`mywebapp`).
    
* `my_app` → The name of your Docker image.
    

Access the Application:

Open your browser at `http://<Docker_host_IP>:5000`

# **4\. Pushing the Image to a Registry**

Now that we have tested the application on a docker container, lets push the docker image that we build to a registry

I am using [***docker hub***](https://hub.docker.com/)***.*** But you can use any other cloud based registry such as [***GitHub Container Registry***](https://github.blog/news-insights/product-news/introducing-github-container-registry/) or a self hosted one such as [***docker registry***](https://www.docker.com/blog/how-to-use-your-own-registry-2/) or [***harbor***](https://goharbor.io/)

If your docker hub repository is a private one, then you will need to authenticate.

> *Use* ***docker login*** *command on your docker host and follow the instructions on the screen*

* Login to registry
    

```bash
root@docker:~# docker login

USING WEB-BASED LOGIN

i Info → To sign in with credentials on the command line, use 'docker login -u <username>'


Your one-time device confirmation code is: XXXX-YYYY
Press ENTER to open your browser or submit your device code here: https://login.docker.com/activate

Waiting for authentication in the browser…

```

* Tag the Image
    

```bash
docker tag my_app <docker_hub-repo_name>/python_picture_webapp:v1
```

* Push the Image
    

```bash
docker push <docker_hub-repo_name>/python_picture_webapp:v1
```

# **5\. Deploying in Kubernetes**

* **Kubernetes manifests used in this project**
    

```bash
apps/python_picture_webapp/
├── deployment.yaml        # Deployment resource for the Flask app
├── service.yaml           # Service to expose the app
├── persistent-volume.yaml # NFS PersistentVolume (PV)
├── persistent-claim.yaml  # PersistentVolumeClaim (PVC)
├── secret.yaml            # Secret for pulling private images from dockerhub
└── namespace.yaml         # Namespace definition (if organizing workloads)
```

* **Create a Secret for accessing the private docker hub**
    

Lets use `kubectl` to create the secret

```python
kubectl create secret docker-registry mycred \
  --docker-server=https://index.docker.io/v1/ \
  --docker-username=<your-username> \
  --docker-password=<your-password> \
  --docker-email=<your-email>
```

This command creates a Secret of type *kubernetes.io/dockerconfigjson*

Retrieve the `.data.dockerconfigjson` field from that new Secret and decode the data:

```python
kubectl get secret mycred -o jsonpath="{.data.\.dockerconfigjson}" | base64 --decode

#Output :

{"auths":{"https://index.docker.io/v1/":{"username":"test-user","password":"your-pass","email":"test@acme.example","auth":"TlJFeG1pY25mMw=="}}}
```

> *Caution:*
> 
> *The* `auth` value there is base64 encoded; it is obscured but not secret. Anyone who can read that Secret can learn the registry access bearer token.

* **Create static persistent volume & claim using a NFS backed storage**
    

On the NFS share, you must have ***rw,sync*** permissions

```python
root@ovm-nfs:~# exportfs -v
/export/nfs_python_pictures_app
                192.168.1.0/24(sync,wdelay,hide,no_subtree_check,rw,secure,no_root_squash,no_all_squash)
```

* *pv\_python-pictures-app.yaml*
    

```yaml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-python-pictures-app
  labels:
    type: nfs
    app: python-picture-webapp
spec:
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  nfs:
    server: 192.168.1.110         # Replace with your NFS server hostname/IP
    path: "/export/nfs_python_pictures_app"        # Replace with your exported directory
```

* *pvc\_python-pictures-app.yaml*
    

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-python-pictures-app
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 1Gi
```

* **Apply the persistent volume & claim manifests**
    

```bash
root@controller01:~# kubectl apply -f pv_python_picture_webapp.yaml
persistentvolume/pv-nfs-python-pictures-app created
root@controller01:~# kubectl apply -f pvc_python_picture_webapp.yaml
persistentvolumeclaim/pvc-python-pictures-app created
```

```bash
root@controller01:~# kubectl get pv,pvc
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                             STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
persistentvolume/nfs-python-pictures-app   1Gi        RWX            Retain           Bound    default/pvc-python-pictures-app                  <unset>                          9d

NAME                                            STATUS   VOLUME                    CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
persistentvolumeclaim/pvc-python-pictures-app   Bound    nfs-python-pictures-app   1Gi        RWX                           <unset>                 9d
```

Lets now put together the ***deployment.yaml***

* *deployment.yaml*
    

```bash
apiVersion: apps/v1
kind: Deployment
metadata:
  name: python-picture-webapp-v1-1
spec:
  replicas: 4  # Number of pods
  selector:
    matchLabels:
      app: python-picture-webapp
  template:
    metadata:
      labels:
        app: python-picture-webapp
        color: blue  
    spec:
      imagePullSecrets:
      - name: mycred   # use the secret created in the begining
      initContainers:  # this init container is used to copy the logo.png to the nfs share
      - name: init-static-images
        image: sumitsur74/python_picture_webapp:v1.1   # your image from your registry
        command: ['sh', '-c', 'cp -r /app/static/images/* /mnt/static/images/']
        volumeMounts:
        - name: nfs-python-pictures-app
          mountPath: /mnt/static
      containers:
      - name: python-picture-webapp
        image: sumitsur74/python_picture_webapp:v1.1  # your image from your registry
        ports:
        - containerPort: 5000
        volumeMounts:
        - name: nfs-python-pictures-app
          mountPath: /app/static  # Mount the static folder
      volumes:
      - name: nfs-python-pictures-app
        persistentVolumeClaim:
          claimName: pvc-python-pictures-app
```

When deploying the app, you might encountered an issue where the *logo.png* kept under `/static/images` directory were not copied to the Persistent Volume (PV). This behavior occurs because, in Kubernetes, mounting a volume to a directory within a container overrides the existing contents of that directory. Consequently, any files baked into the Docker image at that path become inaccessible once the volume is mounted.

* **Use an Init Container to Populate the PV:**
    

An Init Container can be employed to copy the necessary files from the Docker image to the Persistent Volume before the main application container starts. This Init Container copies the contents from `/app/static/images/` (within the Docker image) to `/mnt/static/images/`, which is the mounted Persistent Volume.

The main application container then mounts the same Persistent Volume at `/app/static`, ensuring that the `/app/static/images/` directory contains the necessary files from the PV

```bash
initContainers:  # this init container is used to copy the logo.png to the nfs share
      - name: init-static-images
        image: sumitsur74/python_picture_webapp:v1.1   # your image from your registry
        command: ['sh', '-c', 'cp -r /app/static/images/* /mnt/static/images/']
        volumeMounts:
        - name: nfs-python-pictures-app
          mountPath: /mnt/static
```

Lets prepare the ***service.yaml*** for the networking of the application

If a client makes a request on the node at `http://<NodeIP>:32000`, it will:

🡲Hit port `32000` on the node.

🡲Be forwarded to the service on `port 80`.

🡲 The service will route the request to a pod on `port 5000`.

* *service.yaml*
    

```bash
apiVersion: v1
kind: Service
metadata:
  name: python-picture-service
spec:
  selector:
    app: python-picture-webapp
    color: blue  
  ports:
    - protocol: TCP
      port: 80
      targetPort: 5000
      nodePort: 32000
  type: NodePort
```

* Apply the deployment & service
    

```bash
root@controller01:~/git_codebase/k8s_homelab/apps/python_picture_webapp# kubectl apply -f deployment-v1.1-persistent_init_container.yaml
deployment.apps/python-picture-webapp-v1-1 created

root@controller01:~/git_codebase/k8s_homelab/apps/python_picture_webapp# kubectl apply -f service.yaml
service/python-picture-service created
```

* Verify the pods & service
    

```bash
root@controller01:~# kubectl get pods
NAME                                         READY   STATUS    RESTARTS   AGE
python-picture-webapp-v1-1-8f94986fc-47bh9   1/1     Running   0          2m28s
python-picture-webapp-v1-1-8f94986fc-6chdm   1/1     Running   0          2m28s
python-picture-webapp-v1-1-8f94986fc-88w5q   1/1     Running   0          2m28s
python-picture-webapp-v1-1-8f94986fc-h9lch   1/1     Running   0          2m28s
```

```bash
root@controller01:~# kubectl get service -o wide
NAME                     TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)        AGE   SELECTOR
kubernetes               ClusterIP   10.96.0.1     <none>        443/TCP        76d   <none>
python-picture-service   NodePort    10.96.5.206   <none>        80:32000/TCP   17d   app=python-picture-webapp,color=blue
```

* Access the application at `http://<NodeIP>:32000`
    

![app.png](https://miro.medium.com/v2/resize:fit:700/1*TgO9F-1O2qgIklurOYORuw.png align="left")
