Cloud Explainers

Golang REST API with Gin: Full Tutorial for Beginners

Go (Golang)21 min videoPublished Nov 13, 2026

A full Go and Gin tutorial for beginners: we build a REST API for a plant nursery from an empty folder, on a real Linux laptop. Routes and handlers, structs that turn into JSON (and why every plant first came back as {}), path parameters, query strings, POST with input checks, PUT and DELETE, the crash 300 requests at once cause and the mutex that fixes it, middleware with an API key (and the version anyone could skip), tests with httptest, and one file to ship, for Linux or Windows.

Every plant came back empty

Your first Go API runs, it answers, and every plant comes back as an empty pair of braces. The names and the stock are right there in the code. So where did they go?

laptop: Ana's terminal
curl localhost:8080/plants[{},{},{}]
laptop: Ana's terminal
sed -n '9,20p' main.gotype Plant struct {
    id    int
    name  string
    light string
    stock int
}

var plants = []Plant{
    {1, "Fern", "shade", 12},
    {2, "Basil", "sun", 40},
    {3, "Olive tree", "sun", 2},
}

In this tutorial, we build a REST API in Go with Gin, from an empty folder, for Ana's plant nursery: five routes to list, read, add, change and remove plants, checked input, tests, and one file to ship. On the way, three hundred requests take the server down, and a key check lets anyone in.

What we build, from an empty folder: diagram of curl, nursery-api, GET /plants, GET /plants/:id, POST /plants, PUT /plants/:id, DELETE /plants/:id, 5 routes

What a REST API is

An API is a way for one program to ask another for data. A web API does it over HTTP, the same rules your browser follows. The program that asks is the client. It sends a request with a method, a verb such as GET, and a path, such as /plants. The server answers with a status code, a number that says how it went: 200 means OK. And a body, here in JSON: plain text that programs in any language can read.

One request, one answer: diagram of Client (curl), Ana's API, GET /plants, 200 OK [{"id":1,"name":"Fern",...}], HTTP: the same rules your browser follows

JSON is worth one close look, because every answer in this tutorial is JSON. Curly braces hold one object. Inside, pairs of a key and a value, the key always in double quotes, then a colon. A value in quotes is text; a value without quotes is a number. Square brackets around several objects make a list.

One plant in JSON (three of its keys): diagram of {"id":2,"name":"Basil","stock":40}, "name" = a key, in quotes, "Basil" = text value, 40 = number value, { = one object, [ {...}, {...} ] is a list of objects

REST is a habit for laying out those paths. Each kind of thing gets one path, /plants, one plant gets its number after it, and the verb says what to do: GET reads, POST adds, PUT replaces, and DELETE removes. Four verbs and two paths are the whole map of Ana's API.

Two paths, four verbs: cards of GET /plants · /plants/2, POST /plants, PUT /plants/2, DELETE /plants/2

Inside the server, think of a mail room. Every request is an envelope with two things written on it: the verb, and the address. The router reads both, and drops the envelope into the right pigeonhole. In each pigeonhole waits one function, called a handler, which writes the answer. An envelope with an address nobody set up gets 404: not found. Gin is that router, and a little more.

The router is a mail room: diagram of GET /plants/2, Router, listPlants, getPlant, No such route

Go can already serve web pages on its own: its standard library has a package for HTTP. So why Gin? Gin is a framework built on top of that package, and it adds the parts every API needs. A router that understands paths with parameters in them. Reading JSON in, with checks. Middleware, small functions that run before your handlers. And a test mode. You'll meet each of these, one chapter at a time.

What Gin adds on top of Go: diagram of Ana's handlers, Gin, net/http, Go runtime

Go, a module and Gin

You need Go itself first. The download page on go.dev has an installer for Windows, one for Macs, and an archive for Linux, with the install steps one click away. Ana's laptop runs Ubuntu, and has Go 1.27.4. Every command and the lab files are linked in the description.

Go, a module and Gin: browser screenshot of Ana's laptop

go version confirms it. A Go project is called a module: a folder with a file named go.mod, which gives the project a name and lists the code it uses from others. Ana makes a folder called nursery, steps into it, and go mod init nursery writes that file.

laptop: Ana's terminal
go versiongo version go1.27.4 linux/arm64
laptop: Ana's terminal
mkdir nursery && cd nursery
laptop: Ana's terminal
go mod init nurserygo: creating new go.mod: module nursery

Then she types her first program into main.go. package main and func main mark the program's starting point. The import line brings in Gin. gin.Default() makes a new router, called r. r.GET says: for a GET request to /ping, run this function. The function gets a context, called c, holding the request and a way to answer. c.JSON answers with status 200 and a small JSON object; gin.H is a quick way to write one. And r.Run starts listening on port 8080.

laptop: Ana's terminal
cat main.gopackage main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "pong"})
    })
    r.Run(":8080")
}

Go doesn't have Gin yet. go mod tidy reads the import lines, finds Gin, and downloads it, version 1.12.0, with the 37 packages Gin itself uses. go.mod now lists Gin by name and version, and a second file, go.sum, holds a checksum for every download, so the same build gets the same code on any machine.

laptop: Ana's terminal
go mod tidygo: finding module for package github.com/gin-gonic/gin
go: downloading github.com/gin-gonic/gin v1.12.0
go: found github.com/gin-gonic/gin in github.com/gin-gonic/gin v1.12.0
go: downloading github.com/gin-contrib/sse v1.1.0
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/quic-go/quic-go v0.59.0
go: downloading golang.org/x/net v0.51.0
go: downloading github.com/stretchr/testify v1.11.1
go: downloading go.mongodb.org/mongo-driver/v2 v2.5.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/go-playground/validator/v10 v10.30.1
go: downloading github.com/goccy/go-yaml v1.19.2
go: downloading github.com/pelletier/go-toml/v2 v2.2.4
go: downloading github.com/ugorji/go/codec v1.3.1
go: downloading github.com/json-iterator/go v1.1.12
go: downloading github.com/modern-go/reflect2 v1.0.2
go: downloading github.com/bytedance/sonic v1.15.0
go: downloading github.com/goccy/go-json v0.10.5
go: downloading golang.org/x/sys v0.41.0
go: downloading github.com/davecgh/go-spew v1.1.1
go: downloading github.com/pmezard/go-difflib v1.0.0
go: downloading github.com/gabriel-vasile/mimetype v1.4.12
go: downloading github.com/go-playground/universal-translator v0.18.1
go: downloading github.com/leodido/go-urn v1.4.0
go: downloading golang.org/x/crypto v0.48.0
go: downloading golang.org/x/text v0.34.0
go: downloading github.com/go-playground/locales v0.14.1
go: downloading github.com/quic-go/qpack v0.6.0
go: downloading go.uber.org/mock v0.6.0
go: downloading github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd
go: downloading gopkg.in/yaml.v3 v3.0.1
go: downloading github.com/bytedance/gopkg v0.1.3
go: downloading github.com/cloudwego/base64x v0.1.6
go: downloading golang.org/x/arch v0.22.0
go: downloading github.com/klauspost/cpuid/v2 v2.3.0
go: downloading github.com/bytedance/sonic/loader v0.5.0
go: downloading github.com/twitchyliquid64/golang-asm v0.15.1
go: downloading github.com/go-playground/assert/v2 v2.2.0
go: downloading github.com/google/go-cmp v0.7.0
laptop: Ana's terminal
head -n 5 go.modmodule nursery

go 1.27.4

require github.com/gin-gonic/gin v1.12.0
laptop: Ana's terminal
lsgo.mod  go.sum  main.go

Your first route

Time to run it. In a second window, go run. compiles the folder and starts the program. Gin prints the routes it knows: GET /ping, three handlers; we'll see why in a moment. Then: listening on port 8080. The warnings about debug mode and proxies are for later, when we ship.

laptop: the server window
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080

Back in Ana's terminal, curl, a program that sends HTTP requests, asks for /ping. The answer: message, pong. With -i, curl also prints the status line, 200 OK, and the headers, lines of information about the answer, like its content type: JSON. A path nobody set up, /plants, gets 404, page not found: the envelope with no pigeonhole.

laptop: Ana's terminal
curl localhost:8080/ping{"message":"pong"}
laptop: Ana's terminal
curl -i localhost:8080/pingHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:24:16 GMT
Content-Length: 18

{"message":"pong"}
laptop: Ana's terminal
curl -i localhost:8080/plantsHTTP/1.1 404 Not Found
Content-Type: text/plain
Date: Fri, 13 Nov 2026 02:24:16 GMT
Content-Length: 18

404 page not found

Why three handlers, when Ana wrote one? gin.Default() puts two of its own in front of hers. Logger writes a line for every request. Recovery catches a panic, Go's word for a crash inside a handler, and answers 500, internal server error, instead of letting it take the server down. Each request passes through all three in order, and the answer goes back the same way.

The trip of one request through Gin: diagram of curl, Logger, Recovery, Ana's handler, GET /ping, c.Next(), 200 pong, gin.Default() adds Logger and Recovery in front of yours

Logger's lines are in the server window: the time, the status, how long the answer took, in millionths of a second, and the verb and path. When something looks wrong later, this window is the first place to look.

laptop: the server window
[GIN] 2026/11/13 - 02:24:16 | 200 | 55.083µs |             ::1 | GET      "/ping"
[GIN] 2026/11/13 - 02:24:16 | 200 | 60.625µs |             ::1 | GET      "/ping"
[GIN] 2026/11/13 - 02:24:16 | 404 |    917ns |             ::1 | GET      "/plants"

From a struct to JSON

One habit first: go run doesn't watch your files. After every change, stop the server with Control C, which Go reports as signal: interrupt, and run it again, so it prints the routes it now knows.

laptop: the server window
signal: interrupt
laptop: the server window
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /plants                   --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080

Now the plants. sed -n prints just the lines asked for, here the struct and the list. A struct groups values under one name: each plant has an id, a name, the light it needs, and how many are in stock. Below it, a list of three plants, what Go calls a slice. A new route, GET /plants, hands the whole slice to c.JSON. Ana stops the server, starts it again, and asks for the list. Empty braces, three times: one empty object per plant.

Here's why. Go has a rule about capital letters. A name that starts with a capital letter is exported: code in other packages can see it. A lowercase name is private to its own package. The code that turns values into JSON lives in another package, so it can't see id, name, light or stock. It skips them, without a warning, and each plant becomes an empty object.

Capital letters decide what JSON can see: comparison table of lowercase: private vs Capital: exported, The field, Who can see it, In the JSON

So Ana capitalises the four names. JSON keys are usually lowercase, so each field gets a tag, the text between back quotes: json, colon, id, which says: call this field id in the JSON. A restart, and the list is all there, in one long line. With -s, curl stays silent about its progress, and jq, a small JSON tool, prints one plant per line.

laptop: Ana's terminal
sed -n '9,14p' main.gotype Plant struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Light string `json:"light"`
    Stock int    `json:"stock"`
}
laptop: Ana's terminal
curl localhost:8080/plants[{"id":1,"name":"Fern","light":"shade","stock":12},{"id":2,"name":"Basil","light":"sun","stock":40},{"id":3,"name":"Olive tree","light":"sun","stock":2}]
laptop: Ana's terminal
curl -s localhost:8080/plants | jq -c '.[]'{"id":1,"name":"Fern","light":"shade","stock":12}
{"id":2,"name":"Basil","light":"sun","stock":40}
{"id":3,"name":"Olive tree","light":"sun","stock":2}

A browser is a client too: opening an address sends a GET. So Chromium shows the same list, with its pretty-print switched on.

From a struct to JSON: browser screenshot of GET /plants, from a browser

One plant by its id

One file is getting crowded, so Ana splits it. Files in the same folder share package main, so they use each other's functions with no import. main.go now only lists the routes, and each route names a handler function in handlers.go. The new route has /plants/:id. The colon makes id a path parameter: whatever comes after /plants/ in the address. store.go now keeps the plants in a map instead of a list: Go's lookup table, from each id to its plant.

laptop: Ana's terminal
lsgo.mod  go.sum  handlers.go  main.go  store.go
laptop: Ana's terminal
cat main.gopackage main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()
    r.GET("/plants", listPlants)
    r.GET("/plants/:id", getPlant)
    r.Run(":8080")
}
laptop: Ana's terminal
sed -n '10,16p' store.govar plants = map[int]Plant{
    1: {1, "Fern", "shade", 12},
    2: {2, "Basil", "sun", 40},
    3: {3, "Olive tree", "sun", 2},
}

var nextID = 3

getPlant reads that parameter with c.Param. It arrives as text, so strconv.Atoi, from Go's standard package for converting text, turns it into a number, and gives back an error when it can't. Then the map lookup: ok is false when there's no such plant. For a bad id, answer 400, bad request. For a missing plant, 404. Otherwise, the plant. Ana asks for plant 2, and gets Basil.

laptop: Ana's terminal
sed -n '/^func getPlant/,/^}/p' handlers.gofunc getPlant(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"})
    }
    p, ok := plants[id]
    if !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "plant not found"})
    }
    c.JSON(http.StatusOK, p)
}
laptop: Ana's terminal
curl localhost:8080/plants/2{"id":2,"name":"Basil","light":"sun","stock":40}

Now an id that isn't a number: /plants/abc. The first check should catch it. What do you think curl prints? Three answers, glued together: the error, a second error, and an empty plant.

laptop: Ana's terminal
curl localhost:8080/plants/abc{"error":"id must be a number"}{"error":"plant not found"}{"id":0,"name":"","light":"","stock":0}

c.JSON writes an answer, but it doesn't stop the function. So the code ran on: the lookup failed too, and the last line sent an empty plant. The server window says it plainly: headers were already written. The status line and headers go out first, so once the 400 was sent it couldn't change; the rest was just added on. The fix is one word after each error answer: return. Now abc gets a clean 400, and plant 9 a clean 404.

laptop: the server window, then Ana's terminal
[GIN] 2026/11/13 - 02:24:36 | 200 | 109.667µs |             ::1 | GET      "/plants/2"
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 404
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 200
[GIN] 2026/11/13 - 02:24:36 | 400 | 90.792µs |             ::1 | GET      "/plants/abc"
laptop: the server window, then Ana's terminal
sed -n '/^func getPlant/,/^}/p' handlers.gofunc getPlant(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"})
        return
    }
    p, ok := plants[id]
    if !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "plant not found"})
        return
    }
    c.JSON(http.StatusOK, p)
}
laptop: the server window, then Ana's terminal
curl -i localhost:8080/plants/abcHTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:24:41 GMT
Content-Length: 31

{"error":"id must be a number"}
laptop: the server window, then Ana's terminal
curl -i localhost:8080/plants/9HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:24:41 GMT
Content-Length: 27

{"error":"plant not found"}

Filtering with a query

Customers keep asking Ana for plants for a shady corner. Can the list do that? That's a query string: a question mark after the path, then a name and a value, light equals shade. c.Query reads it, and gives an empty text when it's not there, so the plain list still works. A map has no order in Go, so the matching plants are collected into a list and sorted by id before they go out. Shade: one plant, the fern. Sun: basil and the olive tree.

laptop: Ana's terminal
sed -n '/^func listPlants/,/^}/p' handlers.gofunc listPlants(c *gin.Context) {
    light := c.Query("light")
    list := []Plant{}
    for _, p := range plants {
        if light == "" || p.Light == light {
            list = append(list, p)
        }
    }
    slices.SortFunc(list, func(a, b Plant) int { return a.ID - b.ID })
    c.JSON(http.StatusOK, list)
}
laptop: Ana's terminal
curl "localhost:8080/plants?light=shade"[{"id":1,"name":"Fern","light":"shade","stock":12}]
laptop: Ana's terminal
curl -s "localhost:8080/plants?light=sun" | jq -c '.[]'{"id":2,"name":"Basil","light":"sun","stock":40}
{"id":3,"name":"Olive tree","light":"sun","stock":2}

Path or query? The path names a thing: plant 2 is one plant, and if it doesn't exist, that's a 404. The query narrows a list: light equals shade, and if nothing matches, the answer is an empty list, still 200. A good rule: if the value says which one, use the path. If it says which ones, or how many, use the query.

Path parameter or query string: comparison table of Path /plants/2 vs Query /plants?light=shade, It says, Read with, Nothing there, Use it for

Adding plants with POST

Adding a plant means reading JSON in, not just sending it out. Ana makes a second struct, NewPlant, for what a client may send: no id, because the server picks that. Each field gets a second tag, binding, with rules Gin checks for her. required: the name must be there. oneof: light is sun or shade, nothing else. min=0: no negative stock.

The rules for the Light field, as a tag: diagram of binding:"required,oneof=sun shade", binding = Gin's rules tag, required = must be sent, oneof=sun shade = only these values, Gin checks the binding rules for you

Here is NewPlant in handlers.go. Three fields, each with its json tag and its binding rules. Keeping it apart from Plant means a client can never choose its own id, even by sending one: there's simply no field to put it in.

laptop: Ana's terminal
sed -n '/^type NewPlant/,/^}/p' handlers.gotype NewPlant struct {
    Name  string `json:"name" binding:"required"`
    Light string `json:"light" binding:"required,oneof=sun shade"`
    Stock int    `json:"stock" binding:"min=0"`
}

The handler, createPlant. ShouldBindJSON reads the request's body into the struct and runs every rule. If anything fails, answer 400 with the reason, and return. Otherwise, count nextID up by one, store the plant under it, and answer 201, created, with the new plant, so the client learns its id. Ana registers it with r.POST, and the server lists a third route.

laptop: Ana's terminal
sed -n '/^func createPlant/,/^}/p' handlers.gofunc createPlant(c *gin.Context) {
    var in NewPlant
    if err := c.ShouldBindJSON(&in); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    nextID++
    p := Plant{ID: nextID, Name: in.Name, Light: in.Light, Stock: in.Stock}
    plants[p.ID] = p
    c.JSON(http.StatusCreated, p)
}
laptop: Ana's terminal
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /plants                   --> main.listPlants (3 handlers)
[GIN-debug] GET    /plants/:id               --> main.getPlant (3 handlers)
[GIN-debug] POST   /plants                   --> main.createPlant (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080

With curl, -X picks the verb, POST, and -d sends a body: a mint plant, sun, five in stock. 201 created, and it's plant 4. Now three broken requests. No name: the required rule fails. Light set to moon: the oneof rule. Stock as the word five: the JSON itself doesn't fit an int. Each one a 400 with a reason, and nothing stored.

laptop: Ana's terminal
curl -i -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:24:45 GMT
Content-Length: 46

{"id":4,"name":"Mint","light":"sun","stock":5}
laptop: Ana's terminal
curl -X POST localhost:8080/plants -d '{"light":"sun","stock":5}'{"error":"Key: 'NewPlant.Name' Error:Field validation for 'Name' failed on the 'required' tag"}
laptop: Ana's terminal
curl -X POST localhost:8080/plants -d '{"name":"Mint","light":"moon","stock":5}'{"error":"Key: 'NewPlant.Light' Error:Field validation for 'Light' failed on the 'oneof' tag"}
laptop: Ana's terminal
curl -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":"five"}'{"error":"json: cannot unmarshal string into Go struct field NewPlant.stock of type int"}

jq length counts the plants in the list: four now. Ana restarts the server, and asks again: three. The mint is gone, because the map lives in the program's memory, and a new program starts empty. A real nursery would keep its plants in a database; only store.go would change. The routes and handlers stay as they are.

laptop: Ana's terminal
curl -s localhost:8080/plants | jq length4
laptop: Ana's terminal
curl -s localhost:8080/plants | jq length3

Changing and deleting

Two more routes complete the set. PUT replaces a plant: it checks the id and the body like before, then stores the new version. DELETE removes one, and answers 204, no content: success, with nothing to send back.

laptop: Ana's terminal
grep -n 'r\.' main.go7:  r.GET("/plants", listPlants)
8:  r.GET("/plants/:id", getPlant)
9:  r.POST("/plants", createPlant)
10: r.PUT("/plants/:id", updatePlant)
11: r.DELETE("/plants/:id", deletePlant)
12: r.Run(":8080")
laptop: Ana's terminal
sed -n '/^func deletePlant/,/^}/p' handlers.gofunc deletePlant(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"})
        return
    }
    if _, ok := plants[id]; !ok {
        c.JSON(http.StatusNotFound, gin.H{"error": "plant not found"})
        return
    }
    delete(plants, id)
    c.Status(http.StatusNoContent)
}

Seven olive trees arrived, so Ana puts plant 3 with a stock of seven. She deletes plant 1, the fern: 204. Deleting it again: 404, it's already gone. The list shows two plants, and the olive tree's new stock.

laptop: Ana's terminal
curl -X PUT localhost:8080/plants/3 -d '{"name":"Olive tree","light":"sun","stock":7}'{"id":3,"name":"Olive tree","light":"sun","stock":7}
laptop: Ana's terminal
curl -i -X DELETE localhost:8080/plants/1HTTP/1.1 204 No Content
Date: Fri, 13 Nov 2026 02:24:52 GMT
laptop: Ana's terminal
curl -i -X DELETE localhost:8080/plants/1HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:24:52 GMT
Content-Length: 27

{"error":"plant not found"}
laptop: Ana's terminal
curl -s localhost:8080/plants | jq -c '.[]'{"id":2,"name":"Basil","light":"sun","stock":40}
{"id":3,"name":"Olive tree","light":"sun","stock":7}

That's every status code this API uses so far. The 200s mean it worked: 200 with data, 201 when something new was made, 204 when there's nothing to send. The 400s mean the client got something wrong: 400 for bad input, 404 for nothing there, and soon, 401 for a missing key. And 500 means the server got it wrong. Picking the right one lets every client react without reading your error text.

The status codes Ana's API sends: cards of 200 · 201 · 204, 400, 401 · 404, 500

Many requests at once

So far, one request at a time. A real API gets many at once, and Gin handles each request in its own goroutine: a lightweight thread, so the requests really do run at the same moment. Every one of them reaches the same map.

Many requests, one map: diagram of 30 clients, goroutine, plants

Ana tests that. seq counts to 300, and xargs runs curl for each number, thirty at a time: three hundred new plants, all posted at once. What happens to the server? It dies. Fatal error: concurrent map writes. Ana's next request finds nothing listening.

laptop: Ana's terminal
seq 300 | xargs -P 30 -I{} curl -s -o /dev/null -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'; echo donedone
laptop: Ana's terminal
[GIN] 2026/11/13 - 02:24:52 | 200 | 385.541µs |             ::1 | PUT      "/plants/3"
[GIN] 2026/11/13 - 02:24:52 | 204 |  1.875µs |             ::1 | DELETE   "/plants/1"
[GIN] 2026/11/13 - 02:24:52 | 404 | 49.667µs |             ::1 | DELETE   "/plants/1"
[GIN] 2026/11/13 - 02:24:52 | 200 | 73.708µs |             ::1 | GET      "/plants"
[GIN] 2026/11/13 - 02:24:52 | 201 | 62.958µs |             ::1 | POST     "/plants"
[GIN] 2026/11/13 - 02:24:52 | 201 | 43.958µs |             ::1 | POST     "/plants"
[GIN] 2026/11/13 - 02:24:52 | 201 |     12µs |             ::1 | POST     "/plants"
[GIN] 2026/11/13 - 02:24:52 | 201 | 33.333µs |             ::1 | POST     "/plants"
[GIN] 2026/11/13 - 02:24:52 | 201 | 52.958µs |             ::1 | POST     "/plants"
[GIN] 2026/11/13 - 02:24:52 | 201 | 20.834µs |             ::1 | POST     "/plants"
fatal error: concurrent map writes
[GIN] 2026/11/13 - 02:24:52 | 201 | 30.167µs |             ::1 | POST     "/plants"

goroutine 67 [running]:
internal/runtime/maps.fatal({0x8e7ffd?, 0x1287d05200a0?})
    /usr/local/go/src/runtime/panic.go:1197 +0x20
main.createPlant(0x1287d02fc300)
    /home/ana/nursery/handlers.go:51 +0x1a8
github.com/gin-gonic/gin.(*Context).Next(0x1287d02fc300)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 +0x74
github.com/gin-gonic/gin.CustomRecoveryWithWriter.func1(0x1287d04806c8?)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/recovery.go:90 +0x48
github.com/gin-gonic/gin.(*Context).Next(0x1287d02fc300)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 +0x74
github.com/gin-gonic/gin.LoggerWithConfig.func1(0x1287d02fc300)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/logger.go:282 +0x84
github.com/gin-gonic/gin.(*Context).Next(0x1287d02fc300)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/context.go:192 +0x74
github.com/gin-gonic/gin.(*Engine).handleHTTPRequest(0x1287d033a380, 0x1287d02fc300)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/gin.go:722 +0x344
github.com/gin-gonic/gin.(*Engine).ServeHTTP(0x1287d033a380, {0x127fbd8, 0x1287d0534000}, 0x1287d0526000)
    /home/ana/go/pkg/mod/github.com/gin-gonic/gin@v1.12.0/gin.go:672 +0x178
net/http.serverHandler.ServeHTTP({0x10?}, {0x127fbd8?, 0x1287d0534000?}, 0x1?)
    /usr/local/go/src/net/http/server.go:3440 +0xb0
net/http.(*conn).serve(0x1287d0518120, {0x1280638, 0x1287d0341350})
    /usr/local/go/src/net/http/server.go:2161 +0x5a0
created by net/http.(*Server).Serve in goroutine 1
    /usr/local/go/src/net/http/server.go:3608 +0x3e8
exit status 2
laptop: Ana's terminal
curl localhost:8080/plantscurl: (7) Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server

Two goroutines wrote to the map at the same moment. Go's maps are not built for that, and the runtime, the part of Go that runs your program, checks for it and stops everything rather than keep going with a broken map. The lines below it are the stack trace: the functions that were running, newest on top. Notice Recovery in the stack: it was there, and couldn't help. A panic, the ordinary kind of crash, is what Recovery catches. This is a fatal error instead, and nothing can catch it.

The fix is a mutex, short for mutual exclusion: a lock that only one goroutine can hold at a time. Ana adds one next to the map, and every handler starts with mu.Lock, and defer mu.Unlock, which unlocks when the handler returns, whichever way it leaves. The same 300 requests again. The server is still up, and it holds 303 plants: the first three, plus every one of the 300.

laptop: Ana's terminal
tail -n 2 store.govar mu sync.Mutex
laptop: Ana's terminal
sed -n '/^func createPlant/,/^}/p' handlers.gofunc createPlant(c *gin.Context) {
    mu.Lock()
    defer mu.Unlock()
    var in NewPlant
    if err := c.ShouldBindJSON(&in); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    nextID++
    p := Plant{ID: nextID, Name: in.Name, Light: in.Light, Stock: in.Stock}
    plants[p.ID] = p
    c.JSON(http.StatusCreated, p)
}
laptop: Ana's terminal
seq 300 | xargs -P 30 -I{} curl -s -o /dev/null -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'; echo donedone
laptop: Ana's terminal
curl -s localhost:8080/plants | jq length303

Middleware and a key

Right now, anyone who can reach the server can add or delete plants. Ana wants a key for her staff. A check that runs before handlers is called middleware, like Logger and Recovery. Back in the mail room, it's the desk at the door that checks a badge before an envelope goes in. requireKey compares a header, X-API-Key, a label written on the envelope, with a setting the server reads at start, NURSERY_KEY. No match: AbortWithStatusJSON answers 401, unauthorized, and stops the chain. A match: c.Next passes the request on.

laptop: Ana's terminal
cat middleware.gopackage main

import (
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
)

func requireKey(c *gin.Context) {
    if c.GetHeader("X-API-Key") != os.Getenv("NURSERY_KEY") {
        c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or wrong API key"})
        return
    }
    c.Next()
}

Why not write the key in the code? Code gets copied, into Git and onto laptops, and the key would travel with it. An environment variable is a setting the server is started with, so each machine can have its own key, and the code never holds one.

Where the key lives: comparison table of In the code vs In the environment, Looks like, Copied into Git, To change it, Per machine

To put it only on the staff routes, Ana uses a route group: r.Group, slash plants, with requireKey. POST, PUT and DELETE move into the group. The two GET routes stay outside, open to customers. The server window shows it: the three staff routes now have four handlers: Logger, Recovery, requireKey, and the handler itself.

laptop: Ana's terminal
sed -n '/^func main/,/^}/p' main.gofunc main() {
    r := gin.Default()
    r.GET("/plants", listPlants)
    r.GET("/plants/:id", getPlant)

    staff := r.Group("/plants", requireKey)
    staff.POST("", createPlant)
    staff.PUT("/:id", updatePlant)
    staff.DELETE("/:id", deletePlant)

    r.Run(":8080")
}
laptop: Ana's terminal
go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /plants                   --> main.listPlants (3 handlers)
[GIN-debug] GET    /plants/:id               --> main.getPlant (3 handlers)
[GIN-debug] POST   /plants                   --> main.createPlant (4 handlers)
[GIN-debug] PUT    /plants/:id               --> main.updatePlant (4 handlers)
[GIN-debug] DELETE /plants/:id               --> main.deletePlant (4 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080

Ana started this server with go run, as always. She sends a POST with no key at all. 201, created. The key check let her in. Here's why: NURSERY_KEY was never set, so the server read an empty text. The request had no header, which also reads as an empty text. Empty equals empty, so every request passes. That's the key check anyone could skip.

laptop: Ana's terminal
curl -i -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:25:03 GMT
Content-Length: 46

{"id":4,"name":"Mint","light":"sun","stock":5}

Started with the key set, the check works. No header: 401, unauthorized. The right key in the header: 201. So Ana makes the server refuse to start without a key. main reads it once, and if it's empty, log.Fatal prints why and exits. Now forgetting the key stops the server at once, instead of quietly letting everyone in.

laptop: Ana's terminal
NURSERY_KEY=green-thumb-42 go run .[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /plants                   --> main.listPlants (3 handlers)
[GIN-debug] GET    /plants/:id               --> main.getPlant (3 handlers)
[GIN-debug] POST   /plants                   --> main.createPlant (4 handlers)
[GIN-debug] PUT    /plants/:id               --> main.updatePlant (4 handlers)
[GIN-debug] DELETE /plants/:id               --> main.deletePlant (4 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080
laptop: Ana's terminal
curl -i -X POST localhost:8080/plants -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:25:06 GMT
Content-Length: 36

{"error":"missing or wrong API key"}
laptop: Ana's terminal
curl -i -X POST localhost:8080/plants -H 'X-API-Key: green-thumb-42' -d '{"name":"Mint","light":"sun","stock":5}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Fri, 13 Nov 2026 02:25:06 GMT
Content-Length: 46

{"id":4,"name":"Mint","light":"sun","stock":5}
laptop: Ana's terminal
sed -n '/^func main/,/^}/p' main.gofunc main() {
    key := os.Getenv("NURSERY_KEY")
    if key == "" {
        log.Fatal("NURSERY_KEY is not set")
    }
    setupRouter(key).Run(":8080")
}
laptop: Ana's terminal
go run .2026/11/13 02:25:09 NURSERY_KEY is not set
exit status 1

Tests

Every fix so far was checked by hand, with curl. A test checks it for you, every time. Tests live in files ending in underscore test dot go, and go test finds them by that name. Ana moved the routes into a function, setupRouter, so a test can build the same router. httptest.NewRecorder catches the answer, NewRequest builds a request, and ServeHTTP runs it through Gin with no network at all. If the code isn't 200, or Fern is missing, t.Fatalf, from the test's helper t, fails the test.

laptop: Ana's terminal
sed -n '17,25p' main_test.gofunc TestGetPlant(t *testing.T) {
    r := setupRouter("test-key")
    w := httptest.NewRecorder()
    r.ServeHTTP(w, httptest.NewRequest("GET", "/plants/1", nil))
    if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"name":"Fern"`) {
        t.Fatalf("got %d %s", w.Code, w.Body.String())
    }
}

Three more tests check that a missing plant gets 404, that a bad id gets exactly one answer, and that a POST without the key gets 401. go test -v runs them all, and names each one. Four passes, in a few thousandths of a second.

laptop: Ana's terminal
go test -v=== RUN   TestGetPlant
[GIN] 2026/11/13 - 02:25:15 | 200 | 59.958µs |       192.0.2.1 | GET      "/plants/1"
--- PASS: TestGetPlant (0.00s)
=== RUN   TestMissingPlant
[GIN] 2026/11/13 - 02:25:15 | 404 |  8.417µs |       192.0.2.1 | GET      "/plants/99"
--- PASS: TestMissingPlant (0.00s)
=== RUN   TestCreateNeedsKey
[GIN] 2026/11/13 - 02:25:15 | 401 |   6.25µs |       192.0.2.1 | POST     "/plants"
--- PASS: TestCreateNeedsKey (0.00s)
=== RUN   TestBadID
[GIN] 2026/11/13 - 02:25:15 | 400 |    3.5µs |       192.0.2.1 | GET      "/plants/abc"
--- PASS: TestBadID (0.00s)
PASS
ok      nursery 0.003s

Here's what a test is for. Ana deletes that return again, just for a moment, to bring the bug back, and runs go test. FAIL: TestBadID, with two answers glued together, printed right there, the same bug as before, caught in one second. With the return back in place, ok again.

laptop: Ana's terminal
go test[GIN] 2026/11/13 - 02:25:16 | 200 | 49.792µs |       192.0.2.1 | GET      "/plants/1"
[GIN] 2026/11/13 - 02:25:16 | 404 |  7.375µs |       192.0.2.1 | GET      "/plants/99"
[GIN] 2026/11/13 - 02:25:16 | 401 |  5.875µs |       192.0.2.1 | POST     "/plants"
[GIN] 2026/11/13 - 02:25:16 | 400 |   3.75µs |       192.0.2.1 | GET      "/plants/abc"
--- FAIL: TestBadID (0.00s)
    main_test.go:51: got 400 {"error":"id must be a number"}{"error":"plant not found"}, want 400 {"error":"id must be a number"}
FAIL
exit status 1
FAIL    nursery 0.002s
laptop: Ana's terminal
go test[GIN] 2026/11/13 - 02:25:16 | 200 | 53.416µs |       192.0.2.1 | GET      "/plants/1"
[GIN] 2026/11/13 - 02:25:16 | 404 |  7.667µs |       192.0.2.1 | GET      "/plants/99"
[GIN] 2026/11/13 - 02:25:16 | 401 |  5.792µs |       192.0.2.1 | POST     "/plants"
[GIN] 2026/11/13 - 02:25:16 | 400 |  3.209µs |       192.0.2.1 | GET      "/plants/abc"
PASS
ok      nursery 0.002s

One file to ship

Last, shipping it. go build compiles everything into one file: nursery-api, 28MB. Gin, every package it uses, and Go's own runtime are inside, so the server that runs it needs no Go at all. In the server window, Ana starts the file itself, with GIN_MODE set to release: the debug lines are gone. It answers the same, and Logger still writes its line.

laptop: Ana's terminal
go build -o nursery-api
laptop: Ana's terminal
ls -lh nursery-api-rwxr-xr-x 1 ana ana 28M Nov 13 02:25 nursery-api
laptop: Ana's terminal
GIN_MODE=release NURSERY_KEY=green-thumb-42 ./nursery-api
laptop: Ana's terminal
curl localhost:8080/plants/2{"id":2,"name":"Basil","light":"sun","stock":40}
laptop: Ana's terminal
[GIN] 2026/11/13 - 02:25:19 | 200 | 176.292µs |             ::1 | GET      "/plants/2"

And Go builds for other systems from the same laptop. Set GOOS, the target system, to windows, and GOARCH, the target processor, to amd64, and go build makes a Windows program, an.exe you can copy to a Windows server and run.

laptop: Ana's terminal
GOOS=windows GOARCH=amd64 go build -o nursery-api.exe
laptop: Ana's terminal
file nursery-api.exenursery-api.exe: PE32+ executable (console) x86-64, for MS Windows, 16 sections

Where would Ana take it next? A database, so plants survive a restart: only store.go changes, and a database handles many writers itself. Settings from the environment, like the key, for the port too. A container image, so the file runs the same everywhere. And versioned paths, like /v1/plants, so a later change doesn't break old clients.

Where the nursery API goes next: cards of A database, Settings from the environment, A container image, /v1/plants

Recap

Three things to keep. Capital letters and tags decide what JSON sees. After every error answer, return. And anything handlers share, lock it.

Three things to keep: diagram of Capitals + tags, Answer, then return, Lock what handlers share

If you built along, a like helps YouTube show this to more beginners, and subscribing brings you the next tutorial.

Sources

Demos: a stock Ubuntu 24.04 container on our own lab machine, standing in for Ana's laptop; the plant nursery and its plants are made up. Watch the full video on YouTube.

More videos

Networking11 min

Every Networking Term a Beginner Needs in 12 Minutes

Docker6 min

How to Fix Docker 'Permission Denied' on docker.sock

Git7 min

How to Fix Git 'Updates Were Rejected' When You Push

Linux7 min

How to Fix nginx 502 Bad Gateway Errors on Linux

Linux8 min

How to Fix Linux apt 'Could Not Get Lock' Errors (Ubuntu)

Networking9 min

How Email Works on the Internet (SMTP) in 10 Minutes

Python8 min

The Python Roadmap for Beginners

Networking10 min

How a WireGuard VPN Network Works

Linux9 min

Linux Makefiles Will Finally Make Sense After This

Auth (OAuth, JWT)9 min

SAML vs OAuth vs OpenID Connect: What's the Difference?

Python7 min

How to Fix Python cron Jobs That Never Run

Linux22 min

Linux Bash Scripting: Full Tutorial for Beginners

Networking10 min

How PXE Network Boot Works

Active Directory9 min

How Active Directory Works

Linux10 min

How to Fix a Linux systemd Service That Won't Start

Linux9 min

Linux Logs: syslog vs journald

Auth (OAuth, JWT)9 min

JWT vs Session Cookies: Which One Should You Use?

Docker10 min

Docker vs Docker Compose: Which One Should You Use?

Docker9 min

How to Fix Docker Compose Containers That Can't Talk to Each Other

Django (Python web framework)22 min

Django Full Tutorial for Beginners

Linux9 min

How Linux systemd Works

Networking8 min

How to Fix 'Windows Cannot Access' Network Share Errors (SMB)

APIs10 min

REST vs gRPC: Which One Should You Use?

Certificates & PKI (ADCS)9 min

How to Fix SSL Certificate Chain Errors (Missing Intermediate)

Python9 min

Every Python Concept for Beginners in 10 Minutes

Databases & SQL9 min

Every SQL Join Type in 10 Minutes

OpenRouter (one API for many AI models)9 min

Why OpenRouter Free Models Hit Their Limit So Fast

Gemini API & Google AI Studio9 min

Why Your Gemini API Free Tier Runs Out So Fast

OpenRouter (one API for many AI models)10 min

OpenRouter Will Finally Make Sense After This

Caching & Redis10 min

How Redis Caching Works

Gemini API & Google AI Studio10 min

Gemini API vs Vertex AI: Which One Should You Use?

Gemini API & Google AI Studio8 min

How to Fix a Leaked Gemini API Key

Go (Golang)9 min

Why Golang Uses Goroutines Instead of Threads

Gemini API & Google AI Studio9 min

How to Fix Gemini API Error 429: Resource Exhausted

Claude & the Claude Developer Platform10 min

Claude Token Pricing Will Finally Make Sense After This

Python11 min

Why Python Web Scraping Returns Nothing (and the Fix)

Python10 min

Python uv vs pip: Which One Should You Use?

Python22 min

Python Automation: Full Tutorial for Beginners

Auth (OAuth, JWT)9 min

How OAuth Login Works

Gemini API & Google AI Studio10 min

Google AI Studio Will Finally Make Sense After This

Docker10 min

What Happens When You Run a Docker Container

Claude & the Claude Developer Platform8 min

How to Fix Claude Code Billing Your API Key Instead of Your Plan

Auth (OAuth, JWT)9 min

Why Passkeys Can't Be Phished Like Passwords

PowerShell10 min

PowerShell vs Bash: Which One Should You Learn?

Linux10 min

The Linux Roadmap for Beginners

Django (Python web framework)9 min

Django vs FastAPI: Which One Should You Use?

Databases & SQL10 min

Why SQLite Is the Most Used Database in the World

Databases & SQL11 min

How a NoSQL Database Like MongoDB Works

Python11 min

How to Fix Python pip error: externally-managed-environment

Databases & SQL9 min

How to Fix MySQL Too Many Connections Database Errors

Go (Golang)9 min

Golang Concurrency Will Finally Make Sense After This

AWS10 min

How Amazon S3 Works

Kubernetes10 min

How Kubernetes Works

Git10 min

How Git Works

Docker10 min

How to Fix Docker Port Already Allocated Errors

PostgreSQL10 min

How PostgreSQL Works

Git8 min

How to Fix Git Merge Conflicts

Linux9 min

How to Fix No Space Left on Device in Linux

Networking10 min

How the Internet Works

Databases & SQL9 min

SQL vs NoSQL: When Each One Wins

Auth (OAuth, JWT)8 min

How to Fix JWT Token Expired Errors (401 Unauthorized)

Claude & the Claude Developer Platform9 min

Claude API vs Claude Pro: Why Your Subscription Doesn't Include It

AI agent skills9 min

Why AI Agent Skills Beat Giant Prompts

Linux10 min

Linux Cron Jobs Will Finally Make Sense After This

Cybersecurity10 min

Security Logs Will Finally Make Sense After This

Cybersecurity8 min

How to Fix a Security Leak: An API Key Pushed to GitHub

DNS9 min

DNS Will Finally Make Sense After This

Cybersecurity9 min

Never Ignore a Security Update

Auth (OAuth, JWT)9 min

Two-Factor Authentication Will Finally Make Sense

Networking8 min

Never Expose a Database to the Internet

Cybersecurity10 min

SSH Keys vs Passwords: Which Is Safer for Security?

Linux10 min

The 5 Levels of Linux Boot: From Power On to Login

Networking12 min

The 5 Levels of Network Firewalls

Cybersecurity10 min

Password Security: Why Hackers Crack Weak Passwords

Linux9 min

Why Your Linux Server Runs Out of Memory

Networking10 min

HTTP vs HTTPS: What Actually Changes on Your Network

Linux12 min

Every Linux File Permission You Need to Know

Linux10 min

Linux Processes vs Threads: When Each One Wins

Networking11 min

The OSI Model Will Finally Make Sense After This

Git11 min

The 7 Levels of Git

Networking10 min

Every Network Troubleshooting Command You Need

Cybersecurity10 min

Hashing vs Encryption in Cybersecurity: When Each One Wins

Networking11 min

Why the Internet Ran Out of IP Addresses

Linux11 min

Linux Package Managers: apt vs Snap vs Flatpak

Python9 min

Why Python Is Slow (and When It Doesn't Matter)

Networking11 min

TCP vs UDP: When Each One Wins

Linux8 min

Stop Running Linux as Root (Do This Instead)

Linux11 min

Linux Users and Groups Will Finally Make Sense

Cybersecurity9 min

Why Phishing Beats Good Security (and How to Spot It)

Docker12 min

The 7 Levels of Docker

AI agents & MCP12 min

AI Agents Will Finally Make Sense After This

Git8 min

Stop Using git push --force (Use This Instead)

AI agent skills10 min

Agent Skills vs MCP: When Each One Wins

Networking12 min

Networking Will Finally Make Sense After This

Linux12 min

Every Linux Command You Actually Need

Cybersecurity12 min

The 7 Levels of Cybersecurity

System design12 min

Every System Design Concept You Need, in One Diagram

PostgreSQL10 min

Why PostgreSQL Ignores Your Index