Skip to content
LinkedIn GitHub X

Blog

Exploring Load Balancing in Caddy Using Docker

Cover Images

Hello there! In this post, we’ll dive into the world of Caddy, a modern and powerful web server. Built using Go, Caddy offers a range of built-in features, including reverse proxy and load balancing.

By the way, in this project, I’ll use Caddy 2, the latest version, which comes with a completely rewritten architecture and enhanced features. To make our setup and testing as smooth as possible, we’ll leverage Docker. Docker allows us to create a reproducible environment with all the necessary components easily.

The main focus of this post will be on understanding and experimenting with Caddy’s load balancing algorithms. We’ll explore how Caddy intelligently distributes incoming traffic, the different strategies it offers (such as Round Robin, Least Connection, Random, and more), and how you can configure the for various use cases.

By using Docker, we’ll quickly spin up multiple backend services (which are called “workers”) and route requests through a single Caddy instance acting as our load balancer.

The Project Setup

To get started, let’s look at the project structure. Our setup is straightforward, allowing us to focus on the core concepts of load balancing.

Here’s the project structure:

Terminal window
├── caddy
└── Caddyfile
├── docker-compose.yml
└── src
├── Dockerfile
├── go.mod
└── main.go

Our setup is composed of three main parts:

1. Backend Workers (src): These are simple Go applications the will handle the requests. Each worker will simply return a “Hello form [hostname]” message, allowing us to sess which server is handling the request. This is the perfect way to visualize how Caddy distributes the load.

2. Caddy Load Balancer (caddy): Our Caddy instance will act as the reverse proxy, forwarding requests to the worker services. We’ll use the Caddyfile to define our load balancing rules.

3. Docker Compose (docker.compose.yml): This file orchestrates everything, defining and running our multi-container application with a single-command.

Understanding the Components

Let’s break down the files in our project.

src/main.go

This is a simple Go HTTP server. It listens on port 8081 and responds to any request by printing its hostname. This is our primary tool for observing the load balancing behavior.

package main
import (
"fmt"
"net/http"
"os"
)
func handler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
fmt.Fprintf(w, "Hello from %s\n", hostname)
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8081", nil)
}

src/Dockerfile

This Dockerfile builds our Go application into a lightweight, self-contained Docker image.

FROM golang:1.24 AS builder
WORKDIR /go/src/app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o /go/bin/app
FROM golang:1.24-alpine
COPY --from=builder /go/bin/app /
EXPOSE 8081
ENV PORT 8081
CMD ["/app"]

docker-compose.yml

This file ties everything together. We define a service for our Caddy load balancer and three worker services.

version: "3.8"
services:
load_balancer:
image: caddy:2.10-alpine
container_name: load_balancer
ports:
- "8082:80"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
worker_1:
build: ./src
container_name: worker_1
hostname: worker_1
expose:
- "8081"
worker_2:
build: ./src
container_name: worker_2
hostname: worker_2
expose:
- "8081"
worker_3:
build: ./src
container_name: worker_3
hostname: worker_3
expose:
- "8081"

caddy/Caddyfile

This is where the magic happens! The Caddyfile is Caddy’s configuration file. We’ll define a reverse proxy that routes to our workers. The lb_policy directive is where we’ll specify our load balancing algorithms.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy round_robin
health_uri /
health_interval 3s
}
}

Experimenting with Load Balancing Algorithms

Now that our project is set up, we can start experimenting. To run the project, simply execute docker-compose up in your terminal. You can then send requests to http://127.0.0.1:8082 and observe which worker responds.

1. Round Robin (lb_policy round_robin)

This is the most common and simplest load balancing algorithm. Caddy distributes incoming requests to the backend servers in a sequential, rotating manner. It’s a fair and predictable method, assuming all servers are equally capable of handling the load.

How to Configure:

Modify your caddy/Caddyfile to use the round_robin policy.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy round_robin
health_uri /
health_interval 3s
}
}

How to Test:

After running docker-compose up -d --build, open your terminal and send a few requests using curl. You should see that Caddy distributes the traffic evenly among the three workers.

Terminal window
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1
$ curl http://127.0.0.1:8082
# Output: Hello from worker_2
$ curl http://127.0.0.1:8082
# Output: Hello from worker_3
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1

Now, let’s test Caddy’s fault tolerance. In a real-world scenario, a server might crash or become unresponsive. We’ll simulate this by manually stopping one of the workers.

Run the following command in your terminal to stop worker_1:

Terminal window
$ docker stop worker_1

After a few moments (the health_interval you set, e.g., 3 seconds), Caddy will perform its next health check, detect that worker_1 is unresponsive, and automatically mark it as unhealthy.

Now, send a few more requests. What do you expect to happen? With worker_1 down, Caddy should intelligently stop routing traffic to it and redirect all requests to the remaining healthy servers (worker_2 and worker_3).

Demo Round Robin

2. Weighted Round Robin (lb_policy weighted_round_robin)

This algorithm is a more advanced version of Round Robin. It allows you to assign a “weight” to each backend server, which determines its share of the requests. Servers with a higher weight will receive more traffic than those with a lower weight. This is ideal when you have servers with varying capacities, for example, a new, more powerful server and an older, less powerful one.

You can also use this policy to gradually drain traffic from an old server or ramp up traffic to a new one during deployments, making it a very useful strategy.

How to Configure:

To use this policy, you need to add the weight to each server’s address in the Caddyfile. For our example, let’s give worker_1 a higher weight of 3, while worker_2 and worker_3 each have a weight of 1. This means worker_1 should handle three out of every five requests.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy weighted_round_robin 3 1 1
health_uri /
health_interval 3s
}
}

After updating the Caddyfile, make sure to reload or restart your Caddy container to apply the changes. You can do this with docker-compose up -d --build.

How to Test:

Now, let’s send a few requests to our load balancer and see how Caddy distributes the traffic according to the assigned weights. Send a few requests using curl and observe the responses.

Terminal window
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1
$ curl http://127.0.0.1:8082
# Output: Hello from worker_2
$ curl http://127.0.0.1:8082
# Output: Hello from worker_3
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1

Weighted Round Robin Demo

3. Least Connection (lb_policy ip_hash)

Aight, let’s dive into Least Connection. Unlike Round Robin, which is a simple, sequential algorithm, Least Connection is a dynamic and more intelligent load balancing policy. It chooses the backend server with the fewest number of currently active requests. This policy is excellent for situations where your requests have a highly variable processing time.

For example, if one of your servers gets a handful of complex, long-running requests while the others are handling many small, quick ones, this algorithm will automatically route new traffic to the servers that are less burdened, preventing a single server from becoming a bottleneck. If there’s a tie, meaning two or more servers have the same lowest number of connections, Caddy will randomly choose one of them.

How to Configure:

Configuring this policy is simple. You just need to change the lb_policy directive in your Caddyfile.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy least_conn
health_uri /
health_interval 3s
}
}

After updating your Caddyfile, make sure to restart your Caddy container with docker-compose up -d --build to apply the changes.

How to Test:

To demonstrate the Least Connection algorithm, you’ll need to modify your Go code to simulate a long-running request. This will allow you to see how Caddy intelligently routes traffic away from the busy worker.

- Update Your Go Code

Open your src/main.go file and add a new handler that will simulate a task with a significant delay. This will act as our “long-running request.”

package main
import (
"fmt"
"net/http"
"os"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
fmt.Fprintf(w, "Hello from %s\n", hostname)
}
func longHandler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
time.Sleep(10 * time.Second)
fmt.Fprintf(w, "Hello! long running request finished from %s\n", hostname)
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/long", longHandler)
http.ListenAndServe(":8081", nil)
}

- Rebuild and Run Docker Compose

After updating your code, you must rebuild and run your containers to apply the changes.

Terminal window
$ docker-compose up -d --build

- Test the Scenario

Now, you can test the Least Connection algorithm using two separate terminals.

Terminal 1 (Long-Running Request):

Start a request to the /long endpoint. This will open a connection to one of the workers and hold it for 10 seconds. Caddy will detect that this worker has an active, ongoing connection.

Terminal window
$ curl http://127.0.0.1:8082/long

Terminal 2 (Normal Requests):

Immediately after running the command in Terminal 1, switch to Terminal 2 and send several quick requests to the root endpoint (/).

Terminal window
$ curl http://127.0.0.1:8082/
# Output: Hello from worker_2
$ curl http://127.0.0.1:8082/
# Output: Hello from worker_3
$ curl http://127.0.0.1:8082/
# Output: Hello from worker_2

You will observe that Caddy will not send requests to the worker that is currently busy with the long-running request. Instead, all new requests will be routed to the other two workers, demonstrating how the least_conn algorithm effectively balances load dynamically. Once the long-running request is complete, that worker will once again be available to handle new requests.

Least Connection

4. IP Hash (lb_policy ip_hash)

The IP Hash load balancing algorithm is different from the previous ones because it’s focused on session persistence. Instead of distributing requests based on a sequential or random order, it creates a hash from the client’s IP address and uses that hash to consistently route all requests from that same client to the same backend server.

How to Configure:

Configuring the IP Hash policy is straightforward. You simply need to replace the lb_policy directive in your Caddyfile with ip_hash.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy ip_hash
health_uri /
health_interval 3s
}
}

After updating your Caddyfile, make sure to restart your Caddy container with docker-compose up -d --build to apply the changes.

How to Test:

To test this algorithm, you’ll need to send requests from different “clients” (i.e., different IP addresses) and observe where they are routed. The easiest way to simulate this is by sending requests from your local machine and then using a proxy or a different network to see if the requests are routed to a different server.

IP Hash Demo

No matter how many times you run curl from the same machine, the requests will always be routed to the same worker. This is because Caddy is hashing your local IP address (127.0.0.1 or the container’s internal IP) and consistently mapping it to that specific worker.

This demonstrates how IP Hash ensures session stickiness without needing to share session data across all servers. It’s a powerful tool for maintaining a consistent user experience.

5. Random (lb_policy random)

The Random load balancing policy is the simplest and most unpredictable of all the algorithms. As its name suggests, it selects a backend server at random for each new request. There is no sequential pattern or special logic; every request has an equal chance of being routed to any of the available servers.

While it may seem less sophisticated than other algorithms, the Random policy is surprisingly effective in many scenarios. It’s fast, has a very low overhead, and can be a great choice for distributing traffic evenly across a large pool of homogenous servers. It naturally avoids the “thundering herd” problem that can sometimes occur with Round Robin on first-come-first-served requests, as it prevents all clients from hitting the same server at the same time.

How to Configure:

Configuring this policy is the easiest. Simply replace the lb_policy directive in your Caddyfile with random.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy random
health_uri /
health_interval 3s
}
}

After updating your Caddyfile, make sure to restart your Caddy container with docker-compose up -d --build to apply the changes.

How to Test:

To test the Random policy, send a series of quick requests and observe the output. Unlike the predictable pattern of Round Robin or the consistent output of IP Hash, the responses will come from different workers in an unpredictable order.

Terminal window
curl http://127.0.0.1:8082
# Output: Hello from worker_3
curl http://127.0.0.1:8082
# Output: Hello from worker_1
curl http://127.0.0.1:8082
# Output: Hello from worker_2
curl http://127.0.0.1:8082
# Output: Hello from worker_1

Demo Random

Conclusion

Throughout this post, we’ve explored the core capabilities of Caddy as a powerful web server and a flexible load balancer. Using a simple Docker setup, we were able to quickly demonstrate five different load balancing algorithms, each with its own unique advantages:

- Round Robin: The classic, simple approach for evenly distributing traffic in a predictable sequence.

- Weighted Round Robin: A smarter version of Round Robin that allows you to prioritize traffic to more powerful servers.

- Least Connection: A dynamic algorithm that routes traffic based on real-time load, preventing a single server from becoming a bottleneck.

- IP Hash: The ideal choice for session stickiness, ensuring a consistent user experience by always routing a client to the same backend server.

- Random: A straightforward and fast algorithm for scattering traffic across servers, effective for a large pool of homogenous workers.

This hands-on experience proved that Caddy is not just a simple web server but a robust tool for building scalable and reliable applications. Caddy’s elegant syntax and powerful features make it an excellent choice for anyone looking to simplify their server configurations without sacrificing control or performance. Whether you’re a seasoned developer or just starting, Caddy offers a smooth and intuitive experience that can handle everything from a single website to a complex, distributed application.

References:

- Caddy Documentation: Reverse Proxy

- Caddy Community Wiki

Dockerizing Go API and Caddy

Cover Image

Hello! In this post, I’ll walk through how to use Caddy as a reverse proxy and Docker for containerization to deploy a simple Go API. This method offers a quick and modern way to get your Go API up and running.

Before we dive into the deployment steps, let’s briefly discuss why Docker and Caddy are an excellent combination.

- Docker is a containerization platform that packages your app and all its dependencies into an isolated unit. This guarantees that your app runs consistently everywhere, eliminating the classic it works on my machine problem.

it works on my machine meme

- Dockerile is the blueprint that defines how your Docker image is built. It specifies the base image, the steps to compile your application, and how the container should run.

- Docker Compose is a tool for defining and running multi-container Docker applications. Instead of starting each container manually, we can describe the entire stack in a single YAML file.

- Caddy is a modern reverse proxy and web server built using Go. Caddy is renowned for its ease of use, especially its automatic HTTPS feature. Its simple configuration makes it an ideal choice for serving our API.

Requirements

Before we start, you’ll need to install Go, Docker and Docker Compose on your system.

1. Go

Make sure Go is installed. You can check your version with:

$ go version
go version go1.24.0 linux/amd6

If it’s not installed, you can download it from the official site.

2. Docker & Docker Compose

Make sure Docker & Docker Compose are installed. You can check your docker version with:

Terminal window
$ docker --version
Docker version 27.5.1, build 9f9e405
Terminal window
$ docker compose version
Docker Compose version v2.3.3

Follow the official guides to install Docker and Docker Compose for your operating system if you haven’t installed them before. The official site.

Setting Up the Project

Step 1: Create and Run a Simple Go API

  • First, create a new directory for the project and initialize a Go module:
Terminal window
$ mkdir go-api && cd go-api
$ go mod init example.com/go-api

This command creates a Go module (go.mod) named example.com/go-api, which helps manage dependencies and makes the project reproducible.

- Next, create a new file main.go and define a simple HTTP server using Chi as the router.

The server exposes two routes:

1. Root path / -> return an ASCII banner generated with the go-figure package.

func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
asciiArt := figure.NewFigure("Go API - Caddy", "", true).String()
fmt.Fprintln(w, asciiArt)
}

2. /api/hello -> returns a simple JSON response.

func apiHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message":"Hello, World!"}`)
}

3. In the main function, we:

- Initialize the Chi router and register both routes.

- Configure an HTTP server to listen on port 8081.

- Run the server in a goroutine and listen for shutdown signals (SIGINT, SIGTERM).

- Gracefully shut down the server with a 5-second timeout when a termination signal is received.

func main() {
r := chi.NewRouter()
// API Route
r.Get("/", handler)
r.Get("/api/hello", apiHandler)
srv := &http.Server{
Addr: ":8081",
Handler: r,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
fmt.Println("Server started at :8081")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Could not listen on port 8081: %v\n", err)
}
}()
<-stop
fmt.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Printf("Server shutdown failed: %v\n", err)
}
}

Here is the full code:

package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/common-nighthawk/go-figure"
"github.com/go-chi/chi/v5"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
asciiArt := figure.NewFigure("Go API - Caddy", "", true).String()
fmt.Fprintln(w, asciiArt)
}
func apiHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message":"Hello, World!"}`)
}
func main() {
r := chi.NewRouter()
// API Route
r.Get("/", handler)
r.Get("/api/hello", apiHandler)
srv := &http.Server{
Addr: ":8081",
Handler: r,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
fmt.Println("Server started at :8081")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Could not listen on port 8081: %v\n", err)
}
}()
<-stop
fmt.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Printf("Server shutdown failed: %v\n", err)
}
}

- Then, try run the main.go using go run:

$ go run main.go

- After that, you should see this message in the terminal:

Server started at :8081

- Finally, you can test the API using curl or access from the browser on http://127.0.0.1:8081:

Terminal window
$ curl 127.0.0.1:8081
____ _ ____ ___ ____ _ _
/ ___| ___ / \ | _ \ |_ _| / ___| __ _ __| | __| | _ _
| | _ / _ \ / _ \ | |_) | | | _____ | | / _` | / _` | / _` | | | | |
| |_| | | (_) | / ___ \ | __/ | | |_____| | |___ | (_| | | (_| | | (_| | | |_| |
\____| \___/ /_/ \_\ |_| |___| \____| \__,_| \__,_| \__,_| \__, |
|___/
Terminal window
$ curl 127.0.0.1:8081/api/hello
{"message":"Hello, World!"}

Step 2: The Dockerfile

We’ll use a multi-stage build to create a minimal and secure Docker image. This process compiles our Go application in one stage and then copies only the final binary to a much smaller final image. This keeps our final image size low.

Create a Dockerfile in your project directory and add the following code:

# First stage: builder
FROM golang:1.24 AS builder
WORKDIR /go/src/app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o /go/bin/app

- In the builder stage, we use the official Go image to compile our code.

- We copy the entire project into the container and run go mod download to fetch dependencies.

- Then we build the binary with CGO_ENABLED=0 to ensure it’s statically compiled and portable. The binary is placed in /go/bin/app.

# Second stage: final image
FROM golang:1.24-alpine
# Copy only the compiled binary from builder stage
COPY --from=builder /go/bin/app /
EXPOSE 8081
ENV PORT 8081
CMD ["/app"]

- In the final stage, only the compiled binary is copied over from the builder stage. This keeps the image small because source code, dependencies, and build tools are excluded.

- We expose port 8081 so Docker knows which port the app listens on.

- CMD ["/app"] runs the binary as the container’s main process.

Here the full code:

FROM golang:1.24 AS builder
WORKDIR /go/src/app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o /go/bin/app
FROM golang:1.24-alpine
COPY --from=builder /go/bin/app /
EXPOSE 8081
ENV PORT 8081
CMD ["/app"]

Step 3: Configure Caddy

Caddy will act as a reverse proxy that forwards incoming requests to the Go API container. This allows us to:

  • Tells Caddy to listen on port 80 (HTTP).
  • Forwards requests to the Go API container, using the Docker service name go-api and port 8081.

Create a file named Caddyfile in your project directory:

Terminal window
:80 {
reverse_proxy go-api:8081
}

💡 If you later have a domain (e.g. api.example.com), you can replace :80 with your domain

Terminal window
api.example.com {
reverse_proxy go-api:8081
}

Step 4: Configure Docker Compose

Create a file named docker-compose.yml in your project directory:

version: "3.8"
services:
go-api:
build: .
container_name: go-api
expose:
- "8081" # Expose port internally (only visible to other services in this network)
restart: unless-stopped
caddy:
image: caddy:2.10-alpine
container_name: caddy
ports:
- "8082:80" # Map host port 8082 -> container port 80
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
restart: unless-stopped
depends_on:
- go-api

Explanation

go-api service

- Built from your local Dockerfile.

- The Go API will listen on :8081, but only Caddy can access it.

caddy service

- Uses the official lightweight caddy:2.10-alpine image.

- ports: "8082:80" -> exposes port 80 from the container to port 8082 on your host machine. So when you open http://127.0.0.1:8082, requests are routed through Caddy.

- The Caddyfile is mounted so you can configure reverse proxy behavior.

- depends_on ensures Caddy waits for the Go API container to start.

Step 5: Test the Setup

Once all files are ready (main.go, Dockerfile, Caddyfile, docker-compose.yml), run the stack using docker compose up command:

Terminal window
├── Caddyfile
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
└── main.go
Terminal window
$ docker compose up -d --build

Make sure the containers are running using docker ps command:

Terminal window
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS
NAMES
6b6b35487d77 caddy:2.10-alpine "caddy run --config …" 9 minutes ago Up 9 minutes 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:8082->80/tcp, [::]:8082->80/tcp caddy
20cbb2209fe7 docker-go-api-caddy_go-api "/app" 9 minutes ago Up 9 minutes 0.0.0.0:32770->8081/tcp, [::]:32770->8081/tcp

Now, test the endpoints through Caddy (reverse proxy):

Terminal window
$ curl http://127.0.0.1:8082

Expected output: the ASCII art rendered by go-figure.

Terminal window
$ curl http://127.0.0.1:8082/api/hello

Expected output:

Terminal window
{"message":"Hello, World!"}

Conclusion

In this guide, we successfully deployed a simple Go API containerized approach. By leveraging Docker, we created an isolated and reproducible environment for our application. We used a multi-stage build in our Dockerfile to keep the final image lightweight and secure, and Docker Compose to easily manage both our Go API and Caddy services with a single command.

Feel free to explore further by adding more features to your API or by setting up a domain with Caddy’s automatic HTTPS. Happy deployment!🚀

References:

- Docker Documentation

- Docker Compose Documenation

- Caddy Documentation

Deploying a Simple Flask API Using Gunicorn, Supervisor & Nginx

Post Cover

Intro

Hi there! Flask is great for building APIs quickly. But turning your local project into a publicly accessible web service involves a few extra steps that aren’t always obvious.

In this guide, I’ll show you how to deploy a Flask API using Gunicorn as the WSGI server, Supervisor to manage the process, and Nginx as a reverse proxy.

Overview

- Flask: The Python microframework we’ll use to build the API.

- Gunicorn: A Python WSGI HTTP server for running Flask in production.

- Supervisor: A process control system to ensure the Gunicorn server stays alive.

- Nginx: A reverse proxy to handle client requests and route them to Gunicorn.

Project Flow

The diagram below illustrates the flow of a request and response when using Flask, Gunicorn, Supervisor, and Nginx.

When a user sends an HTTP request, it first reaches the Nginx reverse proxy. Nginx forwards the request to Gunicorn, which serves the Flask application via the WSGI protocol. Supervisor ensures that Gunicorn keeps running and automatically restarts it if needed. The response follows the same path back to the user.

Deployment Flow

Requirements

Before starting, make sure you have the following installed on your system:

- Python 3 and Virtualenv

Check if Python is installed:

Terminal window
$ python3 --version
Python 3.10.14

If not installed, install it:

Ubuntu/Debian:

Terminal window
$ sudo apt update
$ sudo apt install python3 python3-venv -y

CentOS/RHEL:

Terminal window
$ sudo yum install python3 python3-venv -y

Homebrew (macOS):

Terminal window
$ brew install python

- Nginx

Ubuntu/Debian:

Terminal window
$ sudo apt install nginx -y

CentOS/RHEL:

Terminal window
$ sudo yum install nginx -y

Homebrew (macOS):

Terminal window
$ brew install nginx

After installation, check if Nginx is running:

Terminal window
$ sudo systemctl status nginx

If it’s not running, start and enable it:

Terminal window
$ sudo systemctl start nginx
$ sudo systemctl enable nginx
  • Supervisor

Ubuntu/Debian:

Terminal window
$ sudo apt update
$ sudo apt install supervisor -y

CentOS/RHEL:

Terminal window
$ sudo yum install supervisor -y

Homebrew (macOS):

Terminal window
$ brew install supervisor

After installation, check if Supervisor is running:

Terminal window
$ sudo systemctl status supervisor

If it’s not running, start and enable it:

Terminal window
$ sudo systemctl start supervisor
$ sudo systemctl enable supervisor

Setting Up the Flask Project

First, create a project directory and set up a virtual environment:

Terminal window
$ mkdir flask-api && cd flask-api
Terminal window
$ python3 -m venv venv
$ source venv/bin/activate

With the virtual environment activated, install Flask and Gunicorn:

Terminal window
$ pip install flask gunicorn

You can verify the installation:

Terminal window
$ flask --version
$ gunicorn --version

Next, you need to create a file called app.py inside your project directory. You can use any text editor you prefer, such as nano, vim, or others:

Terminal window
$ vim app.py
from flask import Flask
app = Flask(__name__)
@app.route("/api/hello")
def hello():
return {"message": "Hello from Flask API!"}

Then, try to run your Flask app using Gunicorn command:

Terminal window
$ gunicorn app:app
[2025-04-30 20:37:49 +0700] [1085004] [INFO] Starting gunicorn 23.0.0
[2025-04-30 20:37:49 +0700] [1085004] [INFO] Listening at: http://127.0.0.1:8000 (1085004)
[2025-04-30 20:37:49 +0700] [1085004] [INFO] Using worker: sync
[2025-04-30 20:37:49 +0700] [1085005] [INFO] Booting worker with pid: 1085005
[2025-04-30 20:38:58 +0700] [1085004] [INFO] Handling signal: winch

To try your app, just open another terminal session or window. You can use a tool like curl:

Terminal window
$ curl http://127.0.0.1:8000/api/hello
{"message":"Hello from Flask API!"}

Running with Multiple Workers

You can run Gunicorn with multiple worker processes using the -w option. For example, to run your app with 3 workers:

Terminal window
$ gunicorn -w 3 app:app
[2025-04-30 20:49:13 +0700] [1085759] [INFO] Starting gunicorn 23.0.0
[2025-04-30 20:49:13 +0700] [1085759] [INFO] Listening at: http://127.0.0.1:8000 (1085759)
[2025-04-30 20:49:13 +0700] [1085759] [INFO] Using worker: sync
[2025-04-30 20:49:13 +0700] [1085760] [INFO] Booting worker with pid: 1085759
[2025-04-30 20:49:13 +0700] [1085761] [INFO] Booting worker with pid: 1085760
[2025-04-30 20:49:13 +0700] [1085762] [INFO] Booting worker with pid: 1085761

To confirm that Gunicorn is running with multiple workers, you can use tools like top or htop.

Install htop (optional but nicer to read):

Terminal window
$ sudo apt install htop -y

Then run:

Terminal window
$ htop

Gunicorn Process

To bind it to a different port (e.g., 8081) and listen on all interfaces:

Terminal window
$ gunicorn -b 0.0.0.0:8081 app:app
[2025-04-30 21:14:29 +0700] [1085847] [INFO] Starting gunicorn 23.0.0
[2025-04-30 21:14:29 +0700] [1085847] [INFO] Listening at: http://0.0.0.0:8081 (1085847)
[2025-04-30 21:14:29 +0700] [1085847] [INFO] Using worker: sync
[2025-04-30 21:14:29 +0700] [1085848] [INFO] Booting worker with pid: 1085848

Adding Supervisor and Nginx Configuration

Supervisor Setup

To make sure Gunicorn runs in the background and restarts automatically if it crashes, you’ll want to use Supervisor. Here’s how to set it up:

Create a configuration file for your app:

Terminal window
$ sudo vim /etc/supervisor/conf.d/flask-api.conf

Insert this configuration (adjust paths as needed):

Terminal window
[program:flask-api]
directory=/home/youruser/flask-api
command=/home/youruser/flask-api/venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/flask-api.out.log
stderr_logfile=/var/log/flask-api.err.log
environment=PATH="/home/youruser/flask-api/venv/bin"

directory=/home/youruser/flask-api → The working directory where your Flask project is located.

command=/home/youruser/flask-api/venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app

→ Runs the Gunicorn server with 3 workers, binding to localhost on port 8000.

autostart=true → Automatically starts the app when Supervisor starts (e.g., on boot).

autorestart=true → Restarts the app automatically if it crashes.

user=www-data → Runs the process as the www-data user (you can change this to your own user if needed).

stdout_logfile=/var/log/api/flask-api.out.log → File where standard output (including errors) is logged.

stderr_logfile=/var/log/api/flask-api.err.log → (Optional if using redirect_stderr) File for capturing standard error output.

environment=PATH=”…” → Ensures Supervisor uses the correct Python virtual environment for Gunicorn.

Then reload Supervisor to pick up the new config:

Terminal window
$ sudo supervisorctl reread
$ sudo supervisorctl update
$ sudo supervisorctl status

If the API is not running, check the logs:

Terminal window
$ cat /var/log/flask-api.out.log
$ cat /var/log/flask-api.err.log

Or use Supervisor’s built-in log viewer:

Terminal window
$ tail -f flask-api
==> Press Ctrl-C to exit <==

Nginx Configuration

Now that Gunicorn is running your Flask app on port 8000, you’ll want Nginx to act as a reverse proxy.

Create a new Nginx config file:

Terminal window
$ sudo vim /etc/nginx/sites-available/flask-api
Terminal window
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Enable the site and test:

Terminal window
$ sudo ln -s /etc/nginx/sites-available/flask-api /etc/nginx/sites-enabled
$ sudo nginx -t
$ sudo systemctl restart nginx

Finally, ff everything is set up correctly, you should now be able to access your Flask API at http://YOUR_SERVER_IP/api/hello if you’re using a public server or VPS.

Optional: Project Structure & Requirements

To easily reinstall dependencies later:

Terminal window
$ pip freeze > requirements.txt

However, it’s better to specify only the packages you need manually:

Terminal window
$ sudo vim requirements.txt
Terminal window
flask
gunicorn

To install all requirements, just run pip install:

Terminal window
$ pip install -r requirements.txt

Your project structure should look like this:

Terminal window
flask-api/
├── app.py
├── venv/
└── requirements.txt

Common Issues

Here are a few quick troubleshooting tips:

502 Bad Gateway: Usually means Gunicorn isn’t running or the Nginx config has the wrong port.

Supervisor status shows STOPPED: Check your config file paths and the logs: sudo tail -f /var/log/flask-api.err.log

Permission errors: Ensure all paths used by Supervisor and Gunicorn are accessible by the appropriate user.

Conclusion

In this guide, we deployed a Flask API using Gunicorn as the WSGI server, Supervisor to keep the app running reliably, and Nginx as a reverse proxy to handle incoming requests. With this setup, your Flask app is ready to serve real traffic efficiently and automatically recover from crashes. Thanks for reading — and good luck with your deployment! 🚀

Project Reference:

Deploying a Simple Go API with Supervisor and Nginx

Go, Supervisor & Nginx Cover

Intro

Hi! In this post, I’ll show you how to deploy a Go API using Supervisor to manage the process and Nginx as a web server to serve it.

Before we dive into the deployment steps, let’s briefly discuss why we’re using Supervisor and Nginx.

- Supervisor is a process control system that helps manage and monitor applications running in the background. It ensures that your Go API stays up and automatically restarts it if it crashes. See the full documentation

- Nginx is a high-performance web server that can also function as a reverse proxy, making it ideal for serving our Go API to the internet. See the full documentation

🤔 Why Choose Supervisor Over Other Options?

You might wonder why we use Supervisor instead of alternatives like Systemd, PM2, or containerized solutions like Docker. Here’s a quick comparison:

ToolsProsCons
SupervisorSimple setup, great for managing multiple processes, easy log managementRequires manual config
SystemdNative to Linux, faster startupMore complex setup, harder to debug
PM2Built for Node.js, supports process monitoringNot ideal for Go applications
DockerIsolated environment, easy deployment, scalableMore setup overhead, requires container knowledge

When Should You Use Supervisor?

Use Supervisor when you want a non-containerized way to manage a Go service, with features like auto-restart and log management, without dealing with systemd’s complexity or Docker’s extra overhead.

Setup and Run a Simple Go API

Requirements

Before starting, make sure you have the following installed on your system:

- Go

Terminal window
$ go version
go version go1.24.0 linux/amd64

If not installed, download it from the official site.

- Supervisor

  • Ubuntu/Debian

    Terminal window
    $ sudo apt update
    $ sudo apt install supervisor -y
  • CentOS/RHEL

    Terminal window
    $ sudo yum install supervisor -y
  • Homebrew (macOS)

    Terminal window
    $ brew install supervisor

    After installation, check if Supervisor is running:

    Terminal window
    $ sudo systemctl status supervisor

    If it’s not running, start and enable it:

    Terminal window
    $ sudo systemctl start supervisor
    $ sudo systemctl enable supervisor

- Nginx

  • Ubuntu/Debian

    Terminal window
    $ sudo apt install nginx -y
  • CentOS/RHEL

    Terminal window
    $ sudo yum install nginx -y
  • Homebrew (macOS)

    Terminal window
    $ brew install nginx

    After installation, check if Nginx is running:

    Terminal window
    $ sudo systemctl status nginx

    If it’s not running, start and enable it:

    Terminal window
    $ sudo systemctl start nginx
    $ sudo systemctl enable nginx

Initialize a New Go Project

First, create a new directory for the project and initialize a Go module:

Terminal window
$ cd /var/www/
$ mkdir go-api && cd go-api
Terminal window
$ go mod init example.com/go-api

This command creates a Go module named example.com/go-api, which helps manage dependencies.

Create a Simple API

Now, create a new file main.go and add the following code:

Terminal window
$ vim main.go
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, "Simple Go API")
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server started at :8080")
http.ListenAndServe(":8080", nil)
}

Compile and run the Go server:

Terminal window
$ go run main.go

If successful, you should see this message in the terminal:

Terminal window
Server started at :8080

Now test the API using curl:

Terminal window
$ curl localhost:8080
Simple Go API

Create a Simple API with ASCII Text Response (Optional)

First, install the go-figure package:

Terminal window
$ go get github.com/common-nighthawk/go-figure

Now, modify main.go to generate an ASCII text response dynamically:

package main
import (
"fmt"
"net/http"
"github.com/common-nighthawk/go-figure"
)
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
asciiArt := figure.NewFigure("Simple Go API", "", true).String()
fmt.Fprintln(w, asciiArt)
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("Server started at :8080")
http.ListenAndServe(":8080", nil)
}
Terminal window
$ curl localhost:8080
____ _ _ ____ _ ____ ___
/ ___| (_) _ __ ___ _ __ | | ___ / ___| ___ / \ | _ \ |_ _|
\___ \ | | | '_ ` _ \ | '_ \ | | / _ \ | | _ / _ \ / _ \ | |_) | | |
___) | | | | | | | | | | |_) | | | | __/ | |_| | | (_) | / ___ \ | __/ | |
|____/ |_| |_| |_| |_| | .__/ |_| \___| \____| \___/ /_/ \_\ |_| |___|
|_|

Running the API as a Background Service with Supervisor

Create a Supervisor Configuration for the Go API

Create a new Supervisor config file:

Terminal window
$ sudo vim /etc/supervisor/conf.d/go-api.conf

Add the following configuration:

Terminal window
[program:go-api]
process_name=%(program_name)s_%(process_num)02d
directory=/var/www/go-api
command=bash -c 'cd /var/www/go-api && ./main'
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stderr_logfile=/var/log/go-api.err.log
stdout_logfile=/var/log/go-api.out.log

Explanation:

directory=/var/www/go-api → The working directory of the Go API.

command=bash -c 'cd /var/www/go-api && ./main' → Runs the API.

autostart=true → Starts automatically on system boot.

autorestart=true → Restarts if the process crashes.

user=www-data → Runs as the www-data user (adjust as needed).

redirect_stderr=true → Redirects error logs to stdout.

stdout_logfile=/var/log/api/go-api.out.log → Standard output log file.

stderr_logfile=/var/log/api/go-api.err.log → Error log file.

Now, we need build the Go API:

Terminal window
$ go build -o main .

Ensure the directory and binary have the correct permissions:

Terminal window
$ sudo chown -R www-data:www-data /var/www/go-api
$ sudo chmod 775 /var/www/go-api/main

Apply the Supervisor Configuration

Reload Supervisor and start the service:

Terminal window
$ sudo supervisorctl reread
$ sudo supervisorctl update
$ sudo supervisorctl start go-api:*

Check the service status:

Terminal window
$ sudo supervisorctl avail
go-api:go-api_00 in use auto 999:999
Terminal window
$ sudo supervisorctl status go-api:*
go-api:go-api_00 RUNNING pid 198867, uptime 0:01:52

Check Logs and Debugging

If the API is not running, check the logs:

Terminal window
$ cat /var/log/go-api.out.log
$ cat /var/log/go-api.err.log

Or use Supervisor’s built-in log viewer:

Terminal window
$ sudo supervisorctl tail -f go-api:go-api_00
==> Press Ctrl-C to exit <==
Server started at :8080

Setting Up Nginx as a Reverse Proxy for the API

Create a new configuration file:

Terminal window
$ sudo vim /etc/nginx/sites-available/go-api
Terminal window
server {
server_name _;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
error_log /var/log/nginx/go-api_error.log;
access_log /var/log/nginx/go-api_access.log;
}

Create a symbolic link to enable the site:

Terminal window
$ sudo ln -s /etc/nginx/sites-available/go-api /etc/nginx/sites-enabled/

Test the configuration:

Terminal window
$ nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If the test is successful, restart Nginx:

Terminal window
$ sudo systemctl restart nginx

Now, you can access your Go API using:

  • Localhost (if running locally)
Terminal window
curl http://localhost
____ _ _ ____ _ ____ ___
/ ___| (_) _ __ ___ _ __ | | ___ / ___| ___ / \ | _ \ |_ _|
\___ \ | | | '_ ` _ \ | '_ \ | | / _ \ | | _ / _ \ / _ \ | |_) | | |
___) | | | | | | | | | | |_) | | | | __/ | |_| | | (_) | / ___ \ | __/ | |
|____/ |_| |_| |_| |_| | .__/ |_| \___| \____| \___/ /_/ \_\ |_| |___|
|_|
  • Server’s Public IP (if running on a VPS or remote server)
Terminal window
curl http://YOUR_SERVER_IP

Note: If you want to access your Go API using a custom domain instead of an IP address, you need to purchase a domain, configure its DNS to point to your server’s IP, and update your Nginx configuration accordingly. For better security, it’s recommended to set up HTTPS using Let’s Encrypt.

Conclusion

In this guide, we deployed a Go API using Supervisor to manage the process ensuring automatic restarts and efficient request handling also Nginx as a reverse proxy. Thank you for reading, and good luck with your deployment! 🚀

Kubernetes 105: Create Kubernetes Cluster

Kubernetes Cover

Let’s start again. Now we will do some practices.

In this part of the Kubernetes series, we will explore how to create a Kubernetes cluster in different environments. Whether you’re running Kubernetes locally or in the cloud, understanding how to set up a cluster is fundamental to deploying and managing containerized applications efficiently.

We will cover three different ways to create a Kubernetes cluster:

- Kind (Kubernetes in Docker) - A lightweight way to run Kubernetes clusters locally for testing and development.

- K3D (K3S in Docker) - A more lightweight Kubernetes distribution, optimized for local development and CI/CD workflows.

- EKS (Amazon Elastic Kubernetes Service) - A managed Kubernetes service provided by AWS for running Kubernetes workloads in the cloud.

Each approach has its own use cases, advantages, and trade-offs. Let’s dive into each one and see how to set up a cluster.

Setting Up a Kubernetes Cluster with Kind

Kind Logo

Kind (Kubernetes in Docker) is one of the simplest ways to spin up a Kubernetes cluster for local development and testing. It runs Kubernetes clusters inside Docker containers and is widely used for CI/CD and development workflows.

Prerequisites

- Docker installed on your machine. (installation guide)

- KIND CLI installed. (installation guide)

- Kubectl CLI installed. (installation guide)

Create a Cluster with Kind

- Create a new Kind cluster:

Terminal window
$ kind create cluster --name kind-cluster
Creating cluster "kind-cluster" ...
Ensuring node image (kindest/node:v1.31.0) 🖼
Preparing nodes 📦
Writing configuration 📜
Starting control-plane 🕹
Installing CNI 🔌
Installing StorageClass 💾
Set kubectl context to "kind-kind-cluster"
You can now use your cluster with:
kubectl cluster-info --context kind-kind-cluster
Thanks for using kind! 😊
  • Check the cluster:
Terminal window
$ kubectl cluster-info --context kind-kind-cluster
Kubernetes control plane is running at https://127.0.0.1:43417
CoreDNS is running at https://127.0.0.1:43417/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

- List available nodes:

Terminal window
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
kind-cluster-control-plane Ready control-plane 75s v1.31.0

Create Simple Deployment

- Use the kubectl create deployment command to define and start a deployment:

Terminal window
$ kubectl create deployment nginx --image=nginx
deployment.apps/nginx created

- Check deployment status using kubectl get deployment command:

Terminal window
$ kubectl get deployment
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 0/1 1 0 29s

- Expose the deployment:

Terminal window
$ kubectl expose deployment nginx --port=80 --type=LoadBalancer
service/nginx exposed

- Verify the Pod status and then try to access Nginx using your browser:

Terminal window
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-676b6c5bbc-wd87x 1/1 Running 0 12m

Access Nginx

- Delete the cluster when no longer needed

Terminal window
$ kind delete cluster --name kind-cluster
Deleting cluster "kind-cluster" ...
Deleted nodes: ["kind-cluster-control-plane"]

Setting Up a Kubernetes Cluster with K3D

K3D Logo

K3D is a tool that allows you to run lightweight Kubernetes clusters using K3S inside Docker. It is a great choice for fast, local Kubernetes development.

Prerequisites

- Docker installed on your machine. (installation guide)

- K3D CLI installed. (installation guide)

- Kubectl CLI installed. (installation guide)

Create a Cluster with K3D

- Create a new K3D cluster:

Terminal window
$ k3d cluster create my-k3d-cluster
INFO[0000] Prep: Network
INFO[0000] Created network 'k3d-my-k3d-cluster'
INFO[0000] Created image volume k3d-my-k3d-cluster-images
INFO[0000] Starting new tools node...
INFO[0000] Starting node 'k3d-my-k3d-cluster-tools'
INFO[0001] Creating node 'k3d-my-k3d-cluster-server-0'
INFO[0001] Creating LoadBalancer 'k3d-my-k3d-cluster-serverlb'
INFO[0001] Using the k3d-tools node to gather environment information
INFO[0001] HostIP: using network gateway 172.20.0.1 address
INFO[0001] Starting cluster 'my-k3d-cluster'
INFO[0001] Starting servers...
INFO[0001] Starting node 'k3d-my-k3d-cluster-server-0'
INFO[0008] All agents already running.
INFO[0008] Starting helpers...
INFO[0008] Starting node 'k3d-my-k3d-cluster-serverlb'
INFO[0016] Injecting records for hostAliases (incl. host.k3d.internal) and for 2 network members into CoreDNS configma
p...
INFO[0018] Cluster 'my-k3d-cluster' created successfully!
INFO[0018] You can now use it like this:
kubectl cluster-info

- Check the cluster status:

Terminal window
$ kubectl cluster-info
Kubernetes control plane is running at https://0.0.0.0:46503
CoreDNS is running at https://0.0.0.0:46503/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
Metrics-server is running at https://0.0.0.0:46503/api/v1/namespaces/kube-system/services/https:metrics-server:https/p
roxy
To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

- List available nodes:

Terminal window
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k3d-my-k3d-cluster-server-0 Ready control-plane,master 2m33s v1.30.4+k3s1

Create Simple Deployment

- Use the kubectl create deployment command to define and start a deployment:

Terminal window
$ kubectl create deployment httpd --image=httpd

- Check deployment status using kubectl get deployment command:

Terminal window
$ kubectl get deployment
NAME READY UP-TO-DATE AVAILABLE AGE
httpd 0/1 1 0 45s

- Verify the Pod status:

Terminal window
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
httpd-56f946b8c8-84ww8 1/1 Running 0 9m11s

- Expose the deployment:

Terminal window
$ kubectl expose deployment httpd --port=80 --type=LoadBalancer

- Try to access using browser:

Access HTTPD

- Delete the cluster when no longer needed:

Terminal window
$ k3d cluster delete my-k3d-cluster
INFO[0000] Deleting cluster 'my-k3d-cluster'
INFO[0001] Deleting cluster network 'k3d-my-k3d-cluster'
INFO[0001] Deleting 1 attached volumes...
INFO[0001] Removing cluster details from default kubeconfig...
INFO[0001] Removing standalone kubeconfig file (if there is one)...
INFO[0001] Successfully deleted cluster my-k3d-cluster!

Setting Up a Kubernetes Cluster on AWS EKS

AWS EKS Logo

Amazon Elastic Kubernetes Service (EKS) is a fully managed Kubernetes service on AWS, designed for running production workloads.

Prerequisites

- AWS CLI installed and configured. (installation guide)

- EKSCTL CLI installed. (installation guide)

- Kubectl CLI installed. (installation guide)

Create a cluster on EKS

To create a Kubernetes cluster in AWS, you can use the AWS Console (dashboard) or the eksctl CLI. For this guide, we will use eksctl.

We will provisions an EKS cluster with two t4g.small nodes in the us-east-1 region, making it ready for running Kubernetes workloads.

Terminal window
$ eksctl create cluster \
--name cluster-1 \
--region us-east-1 \
--node-type t4g.small \
--nodes 2 \
--nodegroup-name node-group-1
2025-02-01 19:52:35 [ℹ] eksctl version 0.202.0
2025-02-01 19:52:35 [ℹ] using region us-east-1
2025-02-01 19:52:37 [ℹ] setting availability zones to [us-east-1c us-east-1f]
...
2025-02-01 20:02:04 [ℹ] creating addon
2025-02-01 20:02:04 [ℹ] successfully created addon
2025-02-01 20:02:05 [ℹ] creating addon
2025-02-01 20:02:06 [ℹ] successfully created addon
2025-02-01 20:02:07 [ℹ] creating addon
2025-02-01 20:02:07 [ℹ] successfully created addon
"us-east-1" region is ready

- Access AWS console, navigate to the EKS service and you can see the cluster is successfully created.

After cluster creation

- List available nodes:

Terminal window
kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-192-168-xx-yy.ec2.internal Ready <none> 17m v1.30.8-eks-aeac579
ip-192-168-xx-yy.ec2.internal Ready <none> 17m v1.30.8-eks-aeac57

Create Simple Deployment

- Use the kubectl create deployment command to define and start a deployment:

Terminal window
$ kubectl create deployment nginx --image=nginx
deployment.apps/nginx create

- Check deployment status using kubectl get deployment command:

Terminal window
$ kubectl get deployment
NAME READY UP-TO-DATE AVAILABLE AGE
nginx 1/1 1 1 23s

- Verify the Pod status:

Terminal window
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-bf5d5cf98-9dld5 1/1 Running 0 43s

- Expose the service:

Terminal window
$ kubectl expose deployment nginx --type=LoadBalancer --port=80 --name=nginx-service

- Try to access using the browser:

Access the service

- Delete the cluster when no longer needed:

Terminal window
$ eksctl delete cluster --name cluster-1 --region us-east-1
2025-02-01 20:51:59 [ℹ] deleting EKS cluster "cluster-1"
2025-02-01 20:52:02 [ℹ] will drain 0 unmanaged nodegroup(s) in cluster "cluster-1"
2025-02-01 20:52:02 [ℹ] starting parallel draining, max in-flight of 1
2025-02-01 20:52:04 [ℹ] deleted 0 Fargate profile(s)
2025-02-01 20:52:13 [✔] kubeconfig has been updated
2025-02-01 20:52:13 [ℹ] cleaning up AWS load balancers created by Kubernetes objects of Kind Service or Ingress
2025-02-01 20:52:56 [ℹ]
...
2025-02-01 21:02:00 [ℹ] waiting for CloudFormation stack "eksctl-cluster-1-nodegroup-node-group-1"
2025-02-01 21:02:01 [ℹ] will delete stack "eksctl-cluster-1-cluster"
2025-02-01 21:02:04 [✔] all cluster resources were deleted

Conclusion

Setting up a Kubernetes cluster is the first step in running containerized applications at scale. In this guide, we’ve explored three different ways to create a Kubernetes cluster and do a simple deployment: using Kind and K3D for local development and using EKS for cloud-based deployments. Each method has its own advantages depending on your use case.

Thanks for reading this post. Stay tuned!

References:

- KUBERNETES UNTUK PEMULA. https://github.com/ngoprek-kubernetes/buku-kubernetes-pemula.

- How do I install AWS EKS CLI (eksctl)?. https://learn.arm.com/install-guides/eksctl