Containerize React JS and .NET Core Apps using Azure Kubernetics Service (AKS)

Introduction

In this article, we gonna create a sample product application using the.NET Core Web API and React JS. Also, will containerize the same with the help of Azure Kubernetics Services.

Agenda

  • Sample Product Application Backend (.NET Core Web API)
  • Sample Product Application Frontend (React JS)
  • Docker Files for Application
  • Containerize the Application

Prerequisites

  • Visual Studio
  • Docker Runtime and AKS
  • NPM
  • .NET Core SDK
  • React JS

Sample Product Application: Backend (.NET Core Web API)

Step 1. Create a new Product Management.NET Core Web API.

Step 2. First, install the following NuGet packages that we used for the in-memory database.

NuGet packages

Step 3. Add the product class inside the entities folder.

namespace ProductManagementAPI.Entities
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
}

Step 4. Create an AppDbContext class inside the data folder with an in-memory connection and a DB set property.

using Microsoft.EntityFrameworkCore;
using ProductManagementAPI.Entities;
namespace ProductManagementAPI.Data
{
    public class AppDbContext : DbContext
    {
        public DbSet<Product> Products { get; set; }

        public AppDbContext(DbContextOptions<AppDbContext> options)
            : base(options)
        {
        }
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            // This check prevents configuring the DbContext if options are already provided
            if (!optionsBuilder.IsConfigured)
            {
                // Configure the in-memory database here, if needed
                optionsBuilder.UseInMemoryDatabase("InMemoryDb");
            }
        }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Optionally configure entity mappings here
        }
    }
}

Step 5. Add a product repository inside the repositories folder.

IProductRepository

using ProductManagementAPI.Entities;
namespace ProductManagementAPI.Repositories
{
    public interface IProductRepository
    {
        Task<List<Product>> GetAllProductsAsync();
        Task<Product> GetProductByIdAsync(int id);
        Task AddProductAsync(Product product);
        Task UpdateProductAsync(Product product);
        Task DeleteProductAsync(int id);
    }
}

ProductRepository

using Microsoft.EntityFrameworkCore;
using ProductManagementAPI.Data;
using ProductManagementAPI.Entities;

namespace ProductManagementAPI.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly AppDbContext _context;

        public ProductRepository(AppDbContext context)
        {
            _context = context;
        }

        public async Task<List<Product>> GetAllProductsAsync()
        {
            return await _context.Products.ToListAsync();
        }

        public async Task<Product> GetProductByIdAsync(int id)
        {
            return await _context.Products
                .AsNoTracking()
                .FirstOrDefaultAsync(p => p.Id == id);
        }

        public async Task AddProductAsync(Product product)
        {
            if (product == null)
            {
                throw new ArgumentNullException(nameof(product));
            }

            _context.Products.Add(product);
            await _context.SaveChangesAsync();
        }

        public async Task UpdateProductAsync(Product product)
        {
            if (product == null)
            {
                throw new ArgumentNullException(nameof(product));
            }

            _context.Entry(product).State = EntityState.Modified;
            await _context.SaveChangesAsync();
        }

        public async Task DeleteProductAsync(int id)
        {
            var product = await _context.Products.FindAsync(id);
            if (product == null)
            {
                throw new KeyNotFoundException("Product not found.");
            }

            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }
    }
}

Step 6. Create a new product controller with different action methods that we used to perform different operations using our front-end application after invoking the same.

using Microsoft.AspNetCore.Mvc;
using ProductManagementAPI.Entities;
using ProductManagementAPI.Repositories;

namespace ProductManagementAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private readonly IProductRepository _repository;

        public ProductsController(IProductRepository repository)
        {
            _repository = repository;
        }

        [HttpGet]
        public async Task<IActionResult> GetAllProducts()
        {
            var products = await _repository.GetAllProductsAsync();
            return Ok(products); // Returns only the list of products
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetProductById(int id)
        {
            var product = await _repository.GetProductByIdAsync(id);
            if (product == null)
            {
                return NotFound();
            }
            return Ok(product); // Returns only the product data
        }

        [HttpPost]
        public async Task<IActionResult> AddProduct([FromBody] Product product)
        {
            if (product == null)
            {
                return BadRequest();
            }
            await _repository.AddProductAsync(product);
            return CreatedAtAction(nameof(GetProductById), new { id = product.Id }, product);
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateProduct(int id, [FromBody] Product product)
        {
            if (product == null || id != product.Id)
            {
                return BadRequest();
            }
            await _repository.UpdateProductAsync(product);
            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteProduct(int id)
        {
            await _repository.DeleteProductAsync(id);
            return NoContent();
        }
    }
}

Step 7. Register our services inside the service container and configure the middleware.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using ProductManagementAPI.Data;
using ProductManagementAPI.Repositories;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddDbContext<AppDbContext>();
builder.Services.AddCors(options =>
{
    options.AddPolicy("CORSPolicy", builder => builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
});

// Configure in-memory database
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase("InMemoryDb"));

builder.Services.AddControllers();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseCors("CORSPolicy");
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Step 8. Finally, run the application and use Swagger UI to execute different API endpoints.

Swagger UI

Sample Product Application: Frontend (React JS)

Let’s create a client application using React JS and consume the above API endpoints within it.

Step 1. Create a new React JS application with the help of the following command.

npx create-react-app react-netcore-crud-app

Step 2. Navigate to your project directory.

cd react-netcore-crud-app

Step 3. Install Axios to consume and hit backend API and bootstrap for designing purposes.

npm install axios
npm install bootstrap

Step 4. Add the following components and services.

Product list component

// src/components/ProductList/ProductList.js
import React, { useState, useEffect } from 'react';
import ProductListItem from './ProductListItem';
import productService from '../../services/productService';

const ProductList = () => {
    const [products, setProducts] = useState([]);

    useEffect(() => {
        fetchProducts();
    }, []);

    const fetchProducts = async () => {
        try {
            const productsData = await productService.getAllProducts();
            setProducts(productsData);
        } catch (error) {
            console.error('Error fetching products:', error);
        }
    };

    const handleDelete = async (id) => {
        try {
            await productService.deleteProduct(id);
            fetchProducts(); // Refresh product list
        } catch (error) {
            console.error('Error deleting product:', error);
        }
    };

    const handleEdit = () => {
        fetchProducts(); // Refresh product list after editing
    };

    return (
        <div className="container">
            <h2 className="my-4">Product List</h2>
            <ul className="list-group">
                {Array.isArray(products) && products.length > 0 ? (
                    products.map(product => (
                        <ProductListItem
                            key={product.id}
                            product={product}
                            onDelete={() => handleDelete(product.id)}
                            onEdit={handleEdit}
                        />
                    ))
                ) : (
                    <p>No products available</p>
                )}
            </ul>
        </div>
    );
};

export default ProductList;

Product list item component

// src/components/ProductList/ProductListItem.js
import React, { useState } from 'react';
import productService from '../../services/productService';

const ProductListItem = ({ product, onDelete, onEdit }) => {
    const [isEditing, setIsEditing] = useState(false);
    const [editedName, setEditedName] = useState(product.name);
    const [editedPrice, setEditedPrice] = useState(product.price);

    const handleEdit = async () => {
        setIsEditing(true);
    };

    const handleSave = async () => {
        const editedProduct = { ...product, name: editedName, price: parseFloat(editedPrice) };
        try {
            await productService.updateProduct(product.id, editedProduct);
            setIsEditing(false);
            onEdit(); // Refresh product list
        } catch (error) {
            console.error('Error updating product:', error);
        }
    };

    const handleCancel = () => {
        setIsEditing(false);
        // Reset edited values
        setEditedName(product.name);
        setEditedPrice(product.price);
    };

    return (
        <li className="list-group-item">
            {isEditing ? (
                <div className="row">
                    <div className="col">
                        <input
                            type="text"
                            className="form-control"
                            value={editedName}
                            onChange={e => setEditedName(e.target.value)}
                            required
                        />
                    </div>
                    <div className="col">
                        <input
                            type="number"
                            className="form-control"
                            value={editedPrice}
                            onChange={e => setEditedPrice(e.target.value)}
                            required
                        />
                    </div>
                    <div className="col-auto">
                        <button className="btn btn-success me-2" onClick={handleSave}>Save</button>
                        <button className="btn btn-secondary" onClick={handleCancel}>Cancel</button>
                    </div>
                </div>
            ) : (
                <div className="d-flex justify-content-between align-items-center">
                    <span>{product.name} - ${product.price}</span>
                    <div>
                        <button className="btn btn-danger me-2" onClick={onDelete}>Delete</button>
                        <button className="btn btn-primary" onClick={handleEdit}>Edit</button>
                    </div>
                </div>
            )}
        </li>
    );
};

export default ProductListItem;

Product service

// src/services/productService.js
import axios from 'axios';

const baseURL = 'https://localhost:7202/api/products';

const productService = {
    getAllProducts: async () => {
        try {
            const response = await axios.get(baseURL, {
                timeout: 3000,
                headers: {
                    Accept: 'application/json',
                },
            });
            return response.data;
        } catch (err) {
            if (err.code === 'ECONNABORTED') {
                console.log('The request timed out.');
            } else {
                console.log(err);
            }
        }
    },

    addProduct: async (product) => {
        const response = await axios.post(baseURL, product);
        return response.data;
    },

    deleteProduct: async (id) => {
        const response = await axios.delete(`${baseURL}/${id}`);
        return response.data;
    },

    updateProduct: async (id, product) => {
        const response = await axios.put(`${baseURL}/${id}`, product);
        return response.data;
    }
};

export default productService;

App component

// src/App.js
import React, { useState } from 'react';
import ProductList from './components/ProductList/ProductList';
import ProductForm from './components/ProductForm/ProductForm';
function App() {
    const [refresh, setRefresh] = useState(false);
    const handleProductAdded = () => {
        setRefresh(!refresh); // Toggle refresh state to trigger re-render
    };
    return (
        <div>
            <ProductList key={refresh} />
            <ProductForm onProductAdded={handleProductAdded} />
        </div>
    );
}
export default App;

Step 5. Run the application using the following command and perform the different CRUD operations with the help of the same.

CRUD operations

Docker Files for Application

Docker file for backend application (.NET Core).

# Use the official .NET Core SDK as a parent image
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build

WORKDIR /app

# Copy the project file and restore any dependencies (use .csproj for the project name)
COPY *.csproj ./
RUN dotnet restore

# Copy the rest of the application code
COPY . .

# Publish the application
RUN dotnet publish -c Release -o out

# Build the runtime image
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS runtime

WORKDIR /app
COPY --from=build /app/out ./

# Expose the port your application will run on
EXPOSE 80

# Start the application
ENTRYPOINT ["dotnet", "ProductManagementAPI.dll"]
  • Line 1-2: Uses the official .NET Core SDK image (mcr.microsoft.com/dotnet/sdk:6.0) as a base.
  • Line 4: Sets the working directory to /app.
  • Line 6-7: Copies the project file(s) (*.csproj) into the container.
  • Line 8: Runs dotnet restore to restore dependencies specified in the project file(s).
  • Line 10-11: Copies the rest of the application code into the container.
  • Line 13-14: Publishes the application in Release configuration (dotnet publish -c Release -o out), outputting to the out directory.
  • Line 16-17: Uses the official .NET Core ASP.NET runtime image (mcr.microsoft.com/dotnet/aspnet:6.0) as a base.
  • Line 19-20: Sets the working directory to /app and Copies the published output from the build stage (from /app/out) into the /app directory of the runtime stage.
  • Line 22-23: Exposes port 80 to allow external access to the application.
  • Line 25-26: Specifies dotnet ProductManagementAPI.dll as the entry point command to start the application.

Docker file for frontend application (React JS).

FROM node:16-alpine

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]
  • Line 1: specifies the base image, using Node.js version 18.
  • Line 2: sets /app as the working directory for subsequent commands.
  • Line 3: Copies the contents of your local directory into the container's working directory.
  • Line 4: Installs the project dependencies inside the container.
  • Line 5: Build the production version of your React app.
  • Line 6: This exposes port 3000, which is where the application will run.
  • Line 7: The command starts the React application using serve to serve the build folder.

Next, modify your backend hard-coded URL in the product service.

// src/services/productService.js
import axios from 'axios';

const baseURL = `${process.env.REACT_APP_API_URL}/api/products`;

const productService = {
    getAllProducts: async () => {
        try {
            const response = await axios.get(baseURL, {
                timeout: 3000,
                headers: {
                    Accept: 'application/json',
                },
            });

            return response.data;
        } catch (err) {
            if (err.code === 'ECONNABORTED') {
                console.log('The request timed out.');
            } else {
                console.log(err);
            }
        }
    },

    addProduct: async (product) => {
        const response = await axios.post(baseURL, product);
        return response.data;
    },

    deleteProduct: async (id) => {
        const response = await axios.delete(`${baseURL}/${id}`);
        return response.data;
    },

    updateProduct: async (id, product) => {
        const response = await axios.put(`${baseURL}/${id}`, product);
        return response.data;
    }
};

export default productService;

Containerize the front-end and back-end application

Step 1. Open the Azure portal and create a new resource group. (Note: this is the optional step. If you already have your own resource group, then you can use it.)

Azure portal

Resource Group

JD Practice

Step 2. Create a new Azure Container Registry to store the application docker images.

Azure Container Registry

Basics

Review + Create

Step 3. Build and push the application docker images inside the newly created Azure container registry.

Note. Make sure docker runtime is running on your machine to build an image before pushing it into the container registry.

# Build the frontend and backend application image
docker build -t frontendapp -f Dockerfile .
docker build -t backendapp -f Dockerfile .

# Create a new image as per ACR standards for pushing image to ACR
docker tag frontendapp:latest jddemo.azurecr.io/frontendapp:latest
docker tag backendapp:latest jddemo.azurecr.io/backendapp:latest

# Azure login
az login
az acr login --name jddemo

# Push the images
docker push jddemo.azurecr.io/frontendapp:latest
docker push jddemo.azurecr.io/backendapp:latest

If you want more details about how to push and pull the images from ACR, then please check this Microsoft document.

ACR

Step 4. Now, we want Azure Kubernetes service to create and manage our application instances. So, for that, open your Azure resource group and create a new Azure Kubernetes service.

Kubernetes service

Cluster

Kubernetes cluster

Cluster review

Reviewing

Step 5. Next, we are going to create Kubernetes manifest files, and using that, we pull our application images from the ACR, and for that, we have to add ACR PULL access.

To do so, first, open the IAM of ACR.

Click on Role Assignments.

 Role Assignments

Then, under the member's tab, select managed identities and enable ACR Pull access to our AKS Cluster.

ACR Pull access

Step 6. Create new Kubernetes manifest files for both our front-end and back-end applications.

Backend Application Files.

Deployment.YAML

apiVersion: apps/v1
kind: Deployment
metadata:
  name: product-management-api
  labels:
    app: product-management-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: product-management-api
  template:
    metadata:
      labels:
        app: product-management-api
    spec:
      containers:
        - name: product-management-api
          image: jddemo.azurecr.io/backendapp:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 80

Service.YAML

Note. As a part of this article, we have included the service type as Load Balancer because we want to expose and use the backend service inside the front-end application. But, In upcoming articles, we are going to use Cluster IP and DNS, and Ingress configuration is gonna manage those things.

apiVersion: v1
kind: Service
metadata:
  name: product-management-api-service
  labels:
    app: product-management-api
spec:
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  selector:
    app: product-management-api

Client Application Files.

Deployment.YAML

apiVersion: apps/v1
kind: Deployment
metadata:
  name: react-client-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: react-client
  template:
    metadata:
      labels:
        app: react-client
    spec:
      containers:
      - name: react-client
        image: jddemo.azurecr.io/frontendapp:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 80
        env:
        - name: REACT_APP_API_URL
          valueFrom:
            configMapKeyRef:
              name: backend-config
              key: REACT_APP_API_URL

Service.YAML

apiVersion: v1
kind: Service
metadata:
  name: react-client-service
spec:
  selector:
    app: react-client
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: LoadBalancer

Backend-Configmap.YAML

apiVersion: v1
kind: ConfigMap
metadata:
  name: backend-config
data:
  REACT_APP_API_URL: "http://4.186.34.13" //external ip of backend service

Step 7. Apply all the K8S files onto the AKS Cluster.

kubectl apply -f deployment.yml
kubectl apply -f service.yml
kubectl apply -f backend-configmap.yml

Step 8. Verify all the deployments, pods, and services are in running mode with the help of the following commands.

kubectl get deployments
kubectl get pods
kubectl get services

Step 9. Use the external IP Address to use the client application.

Note. As a part of this article, we are not configuring the DNS and Ingress Controller. Because of that, we have to expose external IPs of the application service but in the upcoming articles, we are gonna configure and learn about the same.

External IP Address

Conclusion

In this article, we created a backend and client application with the help of .NET Core API and React JS. Later on, we generated and pushed docker images of the application onto the ACR, and then lastly, with the help of K8S Manifest files we deployed our application on the AKS Cluster.