Member post originally published on ngrok’s blog by Joel Hans

Every autumn, as the weather here in Tucson, Arizona, finally transitions away from near-unbearable heat, my wife once again falls in love with the garden. Ripping out all the desiccated tomatoes and failed watermelon experiments. Turning over and amending the soil. Picking out new seeds. Carefully selecting their locations based on sunlight and the finicky irrigation system. When the day to plant comes, we often do it as a family, our daughters “helping” while my wife and I diligently carve rows and drop in the exact number of seeds at their proper spacing.

And then I immediately forget which seeds went where.

There are simpler, non-technical solutions to this problem—I could, for one, draw simple diagrams of the garden beds and list out all the plants in their respective locations—but one of my daughters would find and desecrate this critical piece of paper with Crayon in a matter of days if not hours.

A far more compelling, technical, and rewarding project became clear: an API for saving plants, locations, and the dates they were sowed/planted, paired with a highly durable deployment on Kubernetes. You can follow along with this developing and deploying project, which has two major “chapters.” First, you’ll build an API using Next.js and MongoDB for receiving requests and saving data, respectively. Next, you’ll move into deploying this API to Kubernetes using Rancher for cluster management and ngrok Ingress Controller for Kubernetes to quickly add public access to your garden tracking API.

You can also clone the API code from the ngrok-samples/nextjs-rancher-ngrok-api repository on GitHub to follow along and/or skip ahead to deployment.

Prerequisites

While the API chapter of this project only requires Node.js on your local workstation, the deployment to Kubernetes via Rancher has more complex prerequisites, including one or more virtual Linux hosts, beyond your local workstation, to operate as a Kubernetes node.

To configure and deploy these prerequisites, follow our Ingress to applications managed by Rancher in Kubernetes  guide for installing Rancher on your local workstation, creating an RKE2 cluster, and deploying the ngrok Kubernetes Ingress Controller.

Chapter 1: Build a REST API for garden tracking

You’re going to use Next.js for this project—it has a nice balance of simplicity for building API routes while also being flexible enough you could later build a frontend web app in the same project with co-located code.

Run the following command to initialize the folder where you will build this project. The first prompt will ask you to name the project (garden-tracker is a good choice!), and for the rest of the questions, hitting Enter to select the defaults is fine.

npx create-next-app@latest

Create a MongoDB connector

You’re going to use the Mongoose library, which provides helper functions that make working with MongoDB a lot easier, so add that library to your project with npm install mongoose.

Create a new src/lib/ folder and add a file named connect-db.ts into it. This function opts for using a cached connection over creating a new one, connecting over a MONGODB_URI environment variable. You don’t need to worry about this variable now—it’ll come back into play once you’re finally ready to deploy to your Kubernetes cluster.

import Mongoose from 'mongoose'

const { MONGODB_URI } = process.env
if (!MONGODB_URI) throw new Error('MONGODB_URI is not defined in environment.')

let cached = global.mongoose
if (!cached) cached = global.mongoose = { conn: null }

const connectMongo = async () => {
  if (cached.conn) return cached.conn

  cached.conn = await Mongoose.connect(MONGODB_URI)
 
  return cached.conn
}

export default connectMongo

Add a file called types.d.ts in the root directory of your project, if one is not there already, to avoid TypeScript errors this database connection.

import { Mongoose } from 'mongoose'

/* eslint-disable no-var */

declare global {
  var mongoose: {
    conn: Mongoose | null;
  };
}

Create a Mongoose schema

Mongoose schema defines how MongoDB will store the documents you’ll eventually create about the plants in your garden. The idea is similar to type safety—if you query your API with data that do not adhere to this schema, MongoDB will either reject the request entirely, such as a missing value for species, or ignore things like additional keys.

TypeScript can “borrow” this schema for type safety, which prevents you from inadvertently changing the type of any variable from a number to a string, for example, which can lead to unexpected and often hard-to-catch bugs in your code. When you instantiate a new variable or object in your TypeScript code, you must also specify its type.

Create a new file called Plants.ts in the src/models/ folder for your new schema, which specifies three keys and requires the species key, which is generally used for naming your documents.

import { models, model, Schema } from 'mongoose'

const PlantSchema: Schema = new Schema({
  species: {
    type: String,
    required: true,
  },
  zone: {
    type: String,
  },
  datePlanted: {
    type: Date,
  }
})

const Plant = models.Plant || model('Plant', PlantSchema)

export default Plant

Add routes for your CRUD API with Next.js Route Handlers

In Next.js 13, Route Handlers let you create a custom request handler for any route. Next.js reads any route.js|ts file inside of the app directory as a Route Handler, and because Next.js uses folder-based routing, you will need to create a src/app/api/plants/ folder structure, and create a new routes.ts within it.

First, you’ll need to bring in the NextResponse middleware function, your MongoDB connector library, and your Mongoose model.

import { NextResponse } from 'next/server'
import connectMongo from '@/lib/connect-db'
import Plant from '@/models/Plants'

Next, you can define your first API route with a handler function that receives a POST request at /api/plants and creates a new document in MongoDB using the request’s data. This function opens a connection with your database via Mongoose, saves the new document, and responds with a success message.

export async function POST(request: Request) {
  try {
    const data = await request.json()

    await connectMongo()

    const savedPlant = await new Plant(data).save()

    return Response.json({
      "message": "Plant created successfully",
      "success": true,
      savedPlant
    })
  } catch (err) {
    return Response.json({ error: `Internal Server Error: ${err}` }, { status: 500 })
  }
}

The next handler function is for a GET request, which responds with all the documents already saved to your database as JSON.

export async function GET() {
  try {
    await connectMongo()

    const res = await Plant.find({})

    return Response.json(res)
  } catch (err) {
    return Response.json({ error: `Internal Server Error: ${err}` }, { status: 500 })
  }
}

The next request in a CRUD API is update, which uses a handler function responding to a PUT request that passes the plant’s _id as part of the route segment, like /api/plants/6539310ebc18ad2f04ed30f9. Next.js makes that segment available in the request’s params, and the following function then uses Mongoose’s findByItAndUpdate function to find a document and update its contents.

This requires a new Next.js dynamic route segment. Create a new folder called [_id] in your existing api/plants/ hierarchy, and then create another route.ts file for the next two functions.

export async function PUT(
    request: Request,
    { params }: { params: { _id: string } }
  ) {
  try {
    await connectMongo()
    const data = await request.json()

    const updatedPlant = await Plant.findByIdAndUpdate(params._id, data, { new: true })

    return Response.json({
      "message": `Plant ${params._id} updated successfully`,
      "success": true,
      updatedPlant
    })
  } catch (err) {
    return Response.json({ error: `Internal Server Error: ${err}` }, { status: 500 })
  }
}

Finally, you’ll likely want to delete plants from your database. This function handler responds to a DELETE request by using the findByIdAndRemove function to delete the relevant document, with the _id from the route path, from your database.

export async function DELETE(
  request: Request,
  { params }: { params: { _id: string } }
) {
  try {
    await connectMongo()

    await Plant.findByIdAndRemove(params._id)

    return Response.json({
      "message": `Plant ${params._id} deleted`
    })
  } catch (err) {
    return Response.json({ error: `Internal Server Error: ${err}` }, { status: 500 })
  }
}

You now have a Next.js-based API for creating, reading, updating, and deleting documents within a MongoDB database of what you’ve planted in your garden. You could start building a frontend for your new API or test/deploy locally using npm run dev, but you’ll jump straight into deploying to a Kubernetes cluster.

Containerize your Next.js API

Because Kubernetes is a container orchestrator, you must package your API code packaged alongside the proper environment and dependencies. Create a new Dockerfile with the following code, which builds your garden tracker project using npm build. Docker then copies the production-ready assets into the image, which exposes port 3000 and runs npm start to launch the server when Kubernetes initializes the image+container in a pod.

FROM node:18-alpine AS build

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine AS runtime

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public

EXPOSE 3000
USER node
CMD ["npm", "start"]

By default, Kubernetes expects to find containers at an existing public or private image registry. You can certainly push your container to one of these registries and configure your Kubernetes cluster accordingly. Still, for the sake of this experiment, you’ll upload your container to ttl.sh, an ephemeral Docker image registry. Later, you’ll configure your deployment to pull this ephemeral image, which your cluster will cache in case you need to rebuild a pod running this image.

Create a UUID for your container, and tag it with 1h, which tells ttl.sh to delete your image after an hour.

IMAGE_NAME=$(uuidgen)
docker build -t ttl.sh/${IMAGE_NAME}:1h .                              	 
docker push ttl.sh/${IMAGE_NAME}:1h

Take note of the output of docker push, notably the first line (The push refers to repository [ttl.sh/IMAGE_NAME], as it’ll inform the Kubernetes manifest you’ll create later.

Chapter 2: Deploy your garden tracker to Kubernetes

The API side of this project is finished, but you still need to deploy it. Assuming you have followed through our  Ingress to applications managed by Rancher in Kubernetes guide for creating a new cluster and installing the ngrok Ingress Controller with Rancher, you’re ready to get moving.

Launch MongoDB on your cluster

Persistent volumes can be a pain in Kubernetes, especially when working on a local cluster, so you’re going to leverage a Rancher-developed library called Local Path Provisioner to enable dynamic, persistent local storage.


kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.24/deploy/local-path-storage.yaml

No more configuration is required for this provisioner, so it’s time to install MongoDB on your cluster, which Rancher can handle entirely through its dashboard. From said dashboard, click Apps → Charts, then search for MongoDB Community Operator. Click on that box, then Install, making sure you add the Operator to the existing ngrok-ingress-controller namespace before finishing the installation.

Create a Kubernetes manifest for your deployment

You’ve finally arrived at the critical moment: time to deploy your garden tracker API to your Kubernetes cluster! The easiest way to do this is a single deployment.yaml manifest with the following content. You’ll need to change three properties:

  user’s credentials.

apiVersion: v1
kind: Service
metadata:
  name: garden-tracker
  namespace: ngrok-ingress-controller
spec:
  ports:
    - name: http
    port: 80
    targetPort: 3000
  selector:
  app: garden-tracker
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: garden-tracker
  namespace: ngrok-ingress-controller
spec:
  replicas: 1
  selector:
    matchLabels:
      app: garden-tracker
  template:
    metadata:
      labels:
        app: garden-tracker
    spec:
      containers:
        - name: api
          image: ttl.sh/{YOUR_IMAGE_UUID}:1h
          ports:
            - name: http
              containerPort: 3000
          env:
            - name: MONGODB_URI
              valueFrom:
                secretKeyRef:
                  name: garden-tracker-mongodb-plants-db-user
                  key: connectionString.standardSrv
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: garden-tracker-ingress
  namespace: ngrok-ingress-controller
spec:
  ingressClassName: ngrok
  rules:
    - host: {YOUR_NGROK_DOMAIN}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: garden-tracker
                port:
                  number: 80
---
apiVersion: mongodbcommunity.mongodb.com/v1
kind: MongoDBCommunity
metadata:
  name: garden-tracker-mongodb
  namespace: ngrok-ingress-controller
spec:
  members: 1
  type: ReplicaSet
  version: "6.0.5"
  security:
  authentication:
    modes: ["SCRAM"]
  users:
    - name: db-user
      db: admin
      passwordSecretRef:
        name: db-user-password
      roles:
        - name: clusterAdmin
          db: admin
        - name: userAdminAnyDatabase
          db: admin
        - name: readWriteAnyDatabase
          db: admin
      scramCredentialsSecretName: my-scram
    - name: db-user
      db: plants
      passwordSecretRef:
        name: db-user-password
      roles:
        - name: readWrite
      	  db: plants
      scramCredentialsSecretName: my-scram
  additionalMongodConfig:
    storage.wiredTiger.engineConfig.journalCompressor: zlib
---
apiVersion: v1
kind: Secret
metadata:
  name: db-user-password
  namespace: ngrok-ingress-controller
type: Opaque
stringData:
  password: {YOUR_DB_PASSWORD}

Apply the manifest you created using the above YAML with kubectl apply -f deployment.yaml. Cross your fingers and let the Kubernetes magic happen.

Wait… what just happened?

In the first Service definition, you’re telling Kubernetes to redirect all traffic arriving on port 80 to pods running the garden-tracker container, which runs on port 3000.

Next, the Deployment for the garden-tracker application pulls your image from ttl.sh and opens up port 3000 to align with the Service. Remember MONGODB_URI from before? Here is where it finally becomes relevant again. When you define a ReplicaSet, which happens later in the manifest, the MongoDB Community Operator initializes a Kubernetes secret with a connection string, like mongodb+srv://USER:PASSWORD@DB.SERVICE.NAMESPACE.svc.cluster.local:27017/COLLECTION?replicaSet=REPLICA_SET&ssl=false. You then pass that secret along to your garden tracker app as an environment variable, which the function in connect-db.ts uses alongside Mongoose.

...
env:
- name: MONGODB_URI
valueFrom:
  secretKeyRef:
  name: garden-tracker-mongodb-plants-db-user
  key: connectionString.standardSrv

The section under kind: MongoDBCommunity defines a ReplicaSet for creating one or more replicated pods running a MongoDB database. Because this is a locally-hosted demo, members: 1 specifies only one replica—if you were deploying to production, you would want to use 3 or more replicas. The configuration under users: creates a new database user with the {YOUR_DB_PASSWORD} you set earlier and grants it the necessary permissions to read and write to the plants database.

The final 9 lines, which define the secret for {YOUR_DB_PASSWORD}, can be deleted after you apply this manifest for the first time, as it is only used during initialization.

Send requests to your API via ngrok ingress

Your deployment is active and your API is ready to receive requests. Now, when you send any request to {YOUR_NGROK_DOMAIN}/apt/plants, ngrok’s cloud edge routes it through a secure tunnel to the ngrok Ingress Controller, which in turn routes it to the pod running your garden-tracker application. You don’t need to configure any other middleware, service meshes, or complex networks to make your application, running on a local cluster, available for public use.

Send a GET request, and you’ll get a near-empty [] in response, as your database has no documents—yet. Try creating a POST request with the following body:

{
  "species": "spinach",
  "zone": "garden bed #1, east",
  "datePlanted": "2023-10-26"
}

And you’ll see a response like this:

{
  "message": "Plant created successfully",
  "success": true,
  "savedPlant": {
    "species": "spinach",
    "zone": "garden bed #1, east",
    "datePlanted": "2023-10-26T00:00:00.000Z",
    "_id": "653ad5b4b1f1b02f1643da7e",
    "__v": 0
  }
}

Send that GET request again to see your stored document:

[
  {
    "_id": "653ad5b4b1f1b02f1643da7e",
    "species": "spinach",
    "zone": "garden bed #1, east",
    "datePlanted": "2023-10-26T00:00:00.000Z",
    "__v": 0
  }
]

Congratulations! Your hard work, at least from a development and operations perspective, is done. It is time to get your hands dirty, maybe with your loved ones, and create some database documents—I mean memories.

What’s next?

The obvious next step is to develop a proper frontend for the garden tracker API, which lists existing documents, lets you create new documents, edit existing ones… and delete everything once summer comes around again and completely fries your hard work—or is that just me, writing from the Arizona desert?

You could build the frontend in the same Next.js project by requesting the content of your database and using setState to create an initial state. Altering said state with every additional request to keep the data synchronized with what’s happening in the database. Perhaps we’ll approach that phase in a future post.

For now, get inspired by how a similar React-based frontend works by checking the vinyl collection CRUD API.

If you’re not keen on anyone having public access to your garden tracker, you could add authentication with OAuth, which allows you to set constraints, like allowing only specific email addresses to access the API, without you having to change a single additional line in your API or (future) frontend code.

Some additional content from our blog you might find useful:

We’d love to know what you loved learning from this project by pinging us on X (aka Twitter) @ngrokhq or LinkedIn, or join our community on Slack.