# Engineering a Local Serverless Stack with OpenFaaS, and MongoDB

Following up on my previous article, I continue exploring how we can **recreate, within on-premises environments, the kind of services and capabilities typically powered by public clouds**.

The underlying question is: *what if we found ourselves in a context where we had to deliver cloud-like services such as serverless architectures, event-driven systems, etc but under strict sovereignty or regulatory constraints that prevent the use of hyperscalers?*

This is the mindset driving this articles serie.

In this article we’ll look at **how to engineer a three-tier serverless architecture**, built around **functions triggered by HTTP requests** that perform computations or operations, and interact with a **NoSQL database for data storage and retrieval**.

The goal is to **replicate a typical cloud-native stack** for example, an **API Gateway + AWS Lambda + DynamoDB** setup but **implemented entirely on-premises**.

To bring this to life, the tools chosen to power the stack are:

* [**OpenFaaS**](https://www.openfaas.com/), serving as the equivalent of **AWS Lambda**,
    
* **MongoDB**, taking the role of **DynamoDB**, and
    
* For the **API Gateway** layer, **OpenFaaS** already includes a built-in gateway **NATS-based**, responsible for routing and invoking functions, similar to the core behavior of AWS API Gateway. On top of that, [**Traefik**](https://traefik.io/traefik) is integrated to provide advanced capabilities such as **CORS handling**, **host-based routing**, and more flexible ingress management. Together, they effectively **recreate the API Gateway’s behavior** without introducing a separate service.
    

To reproduce the setup described in this article, the **main prerequisite** is to have a **Kubernetes cluster** available.

As for the other requirements related to the tools and components of the stack, the necessary instructions will be provided progressively as we move through the article.

## **Database Layer**

To begin, we’ll start by deploying a **MongoDB instance**.

There are no strict requirements regarding the topology of your deployment, this will depend entirely on your specific needs. The key outcome at this stage is simply to have a **working MongoDB instance** that you can connect to and verify using a **MongoDB client** or **mongosh**. You should have a **valid connection URI** allowing you to perform the basic **CRUD operations** (create, read, update, delete).

You can use either the **official MongoDB operator** or the **Percona operator**, in my case, I chose the Percona one.

To deploy MongoDB using the **Percona operator**, you can refer to the setup guide [here](https://docs.percona.com/percona-operator-for-mongodb/minikube.html) .

Once your MongoDB instance is **up and running**, and you’ve successfully tested connectivity through a valid URI with full CRUD capability, you can move on to the next step.

## **Ingress Controller — Managing External Access to Services**

To manage external access to the services, and to configure certain aspects such as [**CORS**](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) **handling**, an **Ingress Controller** is required.

In my case, I chose **Traefik**. Beyond exposing services externally, Traefik also provides **middleware-based CORS management**, allowing fine-grained and declarative configuration.

This setup can also be achieved with **NGINX**, although the approach differs due to **architectural differences** in how each controller extends the Kubernetes Ingress API.

* **NGINX** handles CORS through **annotation-based configurations**, injecting headers like Access-Control-Allow-Origin and processing OPTIONS preflight requests.
    
* **Traefik**, on the other hand, uses **declarative Middleware Custom Resource Definitions (CRDs)** to achieve the same functionality.
    

To deploy Traefik as your Ingress Controller, you can follow the official setup guide [here](https://doc.traefik.io/traefik/getting-started/install-traefik/) .

## **Core Component — OpenFaaS**

Now let’s move to the **core piece** of this entire stack: **OpenFaaS**.

**OpenFaaS®** makes it simple for developers to **deploy event-driven functions and microservices** on Kubernetes **without repetitive boilerplate code**. You can package your code or even an existing binary into a **Docker image**, instantly turning it into a **highly scalable endpoint** with built-in **auto-scaling** and **metrics** support.

To set up OpenFaaS on Kubernetes, follow the installation guide provided [here](https://docs.openfaas.com/deployment/kubernetes/).

For easier management and interaction with your functions, it’s also recommended to install the [**OpenFaaS CLI**](https://docs.openfaas.com/tutorials/cli-with-node/#get-the-cli) on your workstation.

### **Building the Application Logic**

Now that the stack is ready, we can start creating the actual components.

Before diving into the OpenFaaS functions and configurations, let’s first look at the **big picture** of what we’re trying to build.

The goal is simple: a **web page that displays the total number of visitors**, updating each time the site is accessed.

Here’s the general flow:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1761847301176/d6ab185f-3678-4603-9eae-2ace34ff8433.png align="center")

1. When a visitor opens the web page, a small **JavaScript script** (running in the browser) sends an **HTTP request** to our function endpoint.
    
2. The function retrieves the current visitor count from the database, increments it by one, stores the new total, and returns the updated value.
    
3. The web page then displays this new count in real time.
    

You’re free to design your web page as you wish , the frontend doesn’t matter much here. What’s important is the **client-side script** that interacts with the backend.

Below is what that script could look like:

```javascript
const CONFIG = {
  baseUrl: "http://function.loacl", // Replace with your Ingress host url
  endpoint: "/function/visitor-counter", // Single endpoint for both GET and POST
  maxRetries: 3,
  retryDelayMs: 1000,
};

async function apiCall(method = "GET", body = null) {
  const url = `${CONFIG.baseUrl}${CONFIG.endpoint}`;
  const options = {
    method,
    headers: { "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : null,
  };

  let attempts = 0;
  while (attempts < CONFIG.maxRetries) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      return await response.json();
    } catch (error) {
      attempts++;
      if (attempts >= CONFIG.maxRetries) {
        console.error(`Failed after ${attempts} attempts: ${error.message}`);
        throw error;
      }
      const delay = CONFIG.retryDelayMs * Math.pow(2, attempts - 1); // Exponential backoff
      console.warn(
        `Retry ${attempts}/${CONFIG.maxRetries} after ${delay}ms: ${error.message}`,
      );
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

async function updateAndDisplayVisitorCount() {
  const countElement = document.getElementById("visitor-count");
  if (!countElement) {
    console.error("Visitor count element not found");
    return;
  }

  try {
    // Step 1: Increment count (POST to single endpoint)
    await apiCall("POST");

    // Step 2: Fetch updated count (GET to single endpoint)
    const data = await apiCall("GET");
    const count = data.count || 0;

    // Step 3: Update DOM
    countElement.textContent = count;
  } catch (error) {
    countElement.textContent = "Error loading count";
    console.error("Visitor count failed:", error);
  }
}

// Execute on DOM ready
document.addEventListener("DOMContentLoaded", updateAndDisplayVisitorCount);
```

**Understanding the Script**

This JavaScript file is designed to be **self-contained and easily portable**.

You can include it in any HTML page regardless of your frontend framework as long as there’s an element with the ID visitor-count where the number will be displayed.

Here’s a breakdown of what it does:

* **Configuration (CONFIG)**
    
    Defines connection details such as the OpenFaaS gateway URL, the function endpoint, and retry behavior.
    
* **apiCall() function**
    
    A reusable helper that performs HTTP calls (both GET and POST) to the backend function.
    
    It includes **retry logic with exponential backoff** to make the client more resilient to transient network errors.
    
* **updateAndDisplayVisitorCount() function**
    
    Handles the two key operations:
    
    1. Sends a **POST request** to increment the visitor count in the backend.
        
    2. Sends a **GET request** to retrieve the updated total and updates the page accordingly.
        
        If anything fails, it gracefully displays an error message in the UI.
        
* **Execution on page load**
    
    The DOMContentLoaded listener ensures that the visitor count is updated as soon as the page finishes loading.
    

In essence, this script acts as the **frontend bridge** to the backend, showing how a webpage can leverage an serverless architecture to provide real-time, data-driven behavior.

You can also follow along using my example by **cloning my repository** [here](https://github.com/davWK/KNCC.git) , then navigating to the website directory, where you’ll find the source code for the web page.

In that directory, there’s also a simple **Dockerfile** that allows you to **build the website image** and **deploy it on your Kubernetes cluster**.

## **Testing the Frontend Deployment**

At this stage, you can already perform a **first deployment** to verify that everything works correctly on the **frontend side**.

Of course, we’re not concerned with the visitor count yet since the function hasn’t been deployed but you can still validate the website **deployment process**, check that the **services and ingress** are correctly configured, and observe how the **JavaScript behaves** in your browser by opening the browser’s **developer tools** and look at the **console tab** to monitor the network calls and see how the script interacts with the (currently inactive) endpoint.

To deploy the website, you’ll need to create a **Kubernetes Deployment**, a **Service**, and an **Ingress** to expose it externally. if not already created , create k8s namespace for the frontend `kubectl create ns <namespace>`

Below is the example manifest used in this setup:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: website
  namespace: website
spec:
  selector:
    matchLabels:
      app: website
  replicas: 2 
  template:
    metadata:
      labels:
        app: website
    spec:
      containers:
        - name: website
          image: static-site:stable
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: website-service
  namespace: website
spec:
  selector:
    app: website
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: website-http
  namespace: website
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
  rules:
    - host: host-url
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: website-service
                port:
                  number: 80
```

### **Understanding the Manifest**

Let’s break this down piece by piece to make sure everything is clear and connected to the overall architecture.

**1\. Deployment**

This section defines how the website application is deployed as pods within the cluster.

* `replicas: 2` ensures **two pods** are running at all times for **high availability** and **load distribution**.
    
* The containers block specifies the **container image** (static-site:stable) and exposes port 80.
    
* The labels are crucial: they tie this deployment to the service definition that follows.
    

**2\. Service**

The service acts as a **stable network abstraction** over the pods.

* It selects the pods labeled `app: website` (set in the deployment).
    
* It exposes them internally on port 80, mapping traffic to the same target port inside each pod.
    
    This makes it possible for other components such as the Ingress or other services to reach the website reliably, regardless of pod restarts or scaling events.
    

**3\. Ingress**

Finally, the Ingress defines **external access** to your site.

* The annotation `traefik.ingress.kubernetes.io/router.entrypoints: web` tells Traefik to use its HTTP entrypoint.
    
* The rule maps the **host** url to the internal service (website-service).
    
* Any request to / on that host will be routed to port 80 of the service, which then reaches one of your running pods.
    

Once this is applied with `kubectl apply -f filename.yaml`, you can open the host URL in your browser to confirm that the frontend is served correctly.

Even though the visitor counter won’t show up yet, this step ensures the **frontend deployment and routing** are functioning as expected before we move on to the **serverless function layer**.

## OpenFaaS Function creation

Before jumping into the functions, let’s make things easier by exposing the OpenFaaS gateway through an ingress, just like we did for the website.

That way, you’ll have a clean, simple URL to access the OpenFaaS UI, and also a consistent base URL for your functions which keeps everything tidy and predictable.

Here’s the YAML manifest for that:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: openfaas-gateway
  namespace: openfaas
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
  rules:
    - host: host-url
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: gateway
                port:
                  number: 8080
```

**Apply it**

```bash
kubectl apply -f openfaas-ingress.yaml
```

Once it’s applied, you can open:

* **UI:** `host-url/ui`
    
* **Function calls:** `host-url/function/<function-name>`
    

This gives you a single, consistent entrypoint for both the OpenFaaS UI and your deployed functions

### **Building OpenFaaS Function**

Now that the gateway is exposed and ready to receive requests, it’s time to bring the backend logic to life the actual **function** that will handle the visitors count.

First, create a dedicated folder to organize your OpenFaaS functions.

```bash
$ mkdir -p ~/functions && \
  cd ~/functions
```

OpenFaaS engine provides buit-in templates for various languages

To fetch these templates, use the CLI:

```bash
$ faas-cli template pull
```

If the specific Python template is not pulled down, you can pull it directly from its repository:

```bash
$ faas-cli template pull https://github.com/openfaas/python-flask-template.git
```

Now let's scaffold a new Python function :

```bash
$ faas-cli new --lang python visitor-counter
```

After running the command, you’ll see these files:

```bash
visitor-counter/
├── handler.py
├── handler_test.py
├── requirements.txt
└── tox.ini
stack,yaml
```

Now let’s open [handler.py](http://handler.py) and replace its content with the following code:

```python
# handler.py

import os
import logging
from pymongo import MongoClient
from pymongo.errors import PyMongoError

# Setup logging for observability (OpenFaaS captures stdout/stderr to Kubernetes logs)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Decoupled config: Load from env vars (set in OpenFaaS YAML or secrets)
try:
    with open('/var/openfaas/secrets/mongo-uri', 'r') as f:
        MONGO_URI = f.read().strip()
except FileNotFoundError:
    logger.error("Secret file /var/openfaas/secrets/mongo-uri not found")
    MONGO_URI = None  # Fallback; will raise in client

DB_NAME = os.getenv('DB_NAME', 'visitor_db')
COLLECTION_NAME = os.getenv('COLLECTION_NAME', 'counters')
DOCUMENT_ID = os.getenv('DOCUMENT_ID', 'visitor_count')

# Global client for reuse (connection pooling)
client = None

def get_mongo_client():
    global client
    if client is None:
        try:
            client = MongoClient(MONGO_URI)
            logger.info("MongoDB connection established")
        except PyMongoError as e:
            logger.error(f"MongoDB connection failed: {e}")
            raise
    return client

def handle(event, context):
    try:
        client = get_mongo_client()
        db = client[DB_NAME]
        collection = db[COLLECTION_NAME]

        if event.method == 'POST':
            # Atomic increment with upsert (handles initial creation reliably)
            result = collection.update_one(
                {"_id": DOCUMENT_ID},
                {"$inc": {"count": 1}},
                upsert=True
            )
            if result.matched_count > 0 or result.upserted_id:
                logger.info("Visitor count incremented")
                return {
                    "statusCode": 200,
                    "body": {"message": "one visitor added"}
                }
            else:
                raise ValueError("Increment failed")

        elif event.method == 'GET':
            # Fetch count (fallback to 0 if missing, though upsert prevents this)
            doc = collection.find_one({"_id": DOCUMENT_ID})
            count = doc.get("count", 0) if doc else 0
            logger.info(f"Fetched count: {count}")
            return {
                "statusCode": 200,
                "body": {"count": count}
            }

        else:
            # Reliable error for unsupported methods
            return {
                "statusCode": 405,
                "body": {"error": "Method not allowed"}
            }

    except PyMongoError as e:
        logger.error(f"Database error: {e}")
        return {
            "statusCode": 500,
            "body": {"error": "Internal server error"}
        }
    except Exception as e:
        logger.error(f"Unexpected error: {e}")
        return {
            "statusCode": 500,
            "body": {"error": "Internal server error"}
        }
```

Now, let’s **deconstruct** this function carefully .

**Imports**:

```plaintext
import os
import logging
from pymongo import MongoClient
from pymongo.errors import PyMongoError
```

* os: Provides access to environment variables and file paths.
    
* logging: Facilitates structured output for observability; OpenFaaS redirects stdout/stderr to Kubernetes Pod logs, aiding debugging via kubectl logs.
    
* pymongo: The official MongoDB driver for Python, PyMongoError allows targeted exception handling for database-specific failures.
    

**Logging Setup**:

```plaintext
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
```

This initializes a logger at INFO level, capturing key events like connections and operations. Errors or info messages can be queried in tools like kubectl or aggregated via ELK stacks.

**Configuration Loading**:

```python
try:
    with open('/var/openfaas/secrets/mongo-uri', 'r') as f:
        MONGO_URI = f.read().strip()
except FileNotFoundError:
    logger.error("Secret file /var/openfaas/secrets/mongo-uri not found")
    MONGO_URI = None  # Fallback; will raise in client

DB_NAME = os.getenv('DB_NAME', 'visitor_db')
COLLECTION_NAME = os.getenv('COLLECTION_NAME', 'counters')
DOCUMENT_ID = os.getenv('DOCUMENT_ID', 'visitor_count')
```

The MONGO\_URI is read from a file-mounted secret. The try-except block handles missing secrets gracefully, logging the issue while allowing controlled failure. Other values use os.getenv with defaults, enabling overrides via OpenFaaS YAML without code changes.

**Connection Management**:

```python
client = None

def get_mongo_client():
    global client
    if client is None:
        try:
            client = MongoClient(MONGO_URI)
            logger.info("MongoDB connection established")
        except PyMongoError as e:
            logger.error(f"MongoDB connection failed: {e}")
            raise
    return client
```

This implements lazy initialization with a global client for connection pooling. The MongoClient is thread-safe, supporting concurrent requests.

**Request Handling Logic**:

```python
def handle(event, context):
    try:
        client = get_mongo_client()
        db = client[DB_NAME]
        collection = db[COLLECTION_NAME]

        if event.method == 'POST':
            result = collection.update_one(
                {"_id": DOCUMENT_ID},
                {"$inc": {"count": 1}},
                upsert=True
            )
            if result.matched_count > 0 or result.upserted_id:
                logger.info("Visitor count incremented")
                return {
                    "statusCode": 200,
                    "body": {"message": "one visitor added"}
                }
            else:
                raise ValueError("Increment failed")

        elif event.method == 'GET':
            doc = collection.find_one({"_id": DOCUMENT_ID})
            count = doc.get("count", 0) if doc else 0
            logger.info(f"Fetched count: {count}")
            return {
                "statusCode": 200,
                "body": {"count": count}
            }

        else:
            return {
                "statusCode": 405,
                "body": {"error": "Method not allowed"}
            }

    except PyMongoError as e:
        logger.error(f"Database error: {e}")
        return {
            "statusCode": 500,
            "body": {"error": "Internal server error"}
        }
    except Exception as e:
        logger.error(f"Unexpected error: {e}")
        return {
            "statusCode": 500,
            "body": {"error": "Internal server error"}
        }
```

The entry point branches on event.method, supporting a single-handler design for simplicity. For POST, update\_one with $inc ensures atomic increments (guaranteed by MongoDB at the document level), while upsert=True handles initialization without separate checks. The GET uses find\_one with a safe fallback to 0. Unsupported methods return 405 explicitly. Wrapping in try-except isolates failures, returning generic 500 errors to avoid leaking details, while logging specifics for debugging.

In **requirements.txt**, specify the Python package you need:

```bash
pymongo==4.10.0
```

When the function builds, OpenFaaS will install this dependency automatically into the container.

### **Define the Stack Configuration**

Each OpenFaaS function is described in a YAML file, stack.yml.

Let’s define it clearly:

```yaml
# stack.yml
version: 1.0
provider:
  name: openfaas
  gateway: http://127.0.0.1:8080
functions:
  visitor-counter:
    lang: python3-http
    handler: ./visitor-counter
    image: ghcr.io/davwk/visitor-counter:v1 # Replace with your registry
    environment:
      DB_NAME: visitor_db
      COLLECTION_NAME: counters
      DOCUMENT_ID: visitor_count
    secrets:
      - mongo-uri
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 200m
      memory: 256Mi
```

* `provider.gateway` → the endpoint of your OpenFaaS deployment.
    
* `functions` → list of deployed functions (you can have many).
    
* `lang, handler, image` → define how the function is built and deployed.
    
* `secrets` → links the function to the mongo-uri secret.
    
* `requests/limits` → resource boundaries for Kubernetes scheduling.
    

### **Create the MongoDB Secret**

Now let’s create the mongo-uri secret before deploying:

```bash
$ faas-cli secret create mongo-uri --from-literal="<your-mongodb-uri>"
```

OpenFaaS will automatically mount it under /var/openfaas/secrets/mongo-uri during runtime.

### **Build and Deploy**

Build the function and deploy it to your OpenFaaS cluster:

```bash
$ faas-cli build -f ./stack.yml
$ faas-cli deploy -f ./stack.yml
```

Once the deployment completes, you can check the UI or test directly via curl:

```bash
curl -X POST baseurl/function/visitor-counter -v  # Expect 200 {"message": "one visitor added"}
curl -X GET baseurl/function/visitor-counter -v   # Expect 200 {"count": N}
```

Your website’s JavaScript file is responsible for sending requests to the backend function. Initially, it probably pointed to a placeholder URL. Now that your function is actually deployed and reachable through your ingress, you should **update that URL if required**.

Once you’ve updated the JS file, you need to **rebuild your website image** and redeploy it to Kubernetes so the changes take effect.**Test the Full Stack**

Now open your browser and navigate to: [**yourwebsite-url**](http://website.127.0.0.1.sslip.io)

When the page loads:

* The frontend JavaScript automatically triggers the **POST** request → your OpenFaaS function increments the counter in MongoDB.
    
* Then it sends a **GET** request → retrieves the new value and displays it on the page.
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1761823947211/39c58cf5-5256-412a-b16b-0b01d40dc616.png align="center")

## **Troubleshooting CORS Handling Errors**

If things don’t work as expected, the most likely issue you’ll face is related to **CORS (Cross-Origin Resource Sharing)**.

To confirm this, inspect the **Console** tab in your browser’s developer tools if you see CORS-related errors, it’s because the **host URL of your website** is different from the **host URL of your function gateway**.

CORS is a browser security mechanism designed to prevent JavaScript code from calling resources from other domains that might execute malicious actions or leak data. So when your frontend and function gateway don’t share the same domain or subdomain, the browser blocks the request by default.

To fix this issue when using **Traefik**, you can create a **CORS middleware** resource to explicitly allow requests from your website’s origin (or use a wildcard \* if you’re working in a development environment).  
So that’s all for this article , thank you for reading, and I hope it was useful to you.

```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: cors
  namespace: openfaas
spec:
  headers:
    accessControlAllowMethods: ["GET", "POST", "OPTIONS"]
    accessControlAllowOriginList: ["*"] # Wildcard for dev; restrict to your site origin (e.g., ["http://website.127.0.0.1.nip.io"]) for production
    accessControlAllowHeaders: ["Content-Type"]
    accessControlAllowCredentials: false
    accessControlMaxAge: 100
    addVaryHeader: true
```

After applying this middleware, update your **Ingress** for the function gateway to reference it using the annotation below:

```yaml
traefik.ingress.kubernetes.io/router.middlewares: openfaas-cors@kubernetescrd
```

Your final Ingress definition should look like this:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: openfaas-gateway
  namespace: openfaas
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
    traefik.ingress.kubernetes.io/router.middlewares: openfaas-cors@kubernetescrd
spec:
  # rest of the definition
```

Once both configurations are applied, the CORS issue should be resolved, allowing your site to communicate properly with your deployed functions.
