A Go (Gin) API Deployed in Five Steps

A link shortener in one main.go, compiled to a single binary by Light Cloud. It starts in a quarter of a second, logs in JSON, and shuts down cleanly.

A Go (Gin) API Deployed in Five Steps
On this pageShow
  1. What you will build
  2. Before you start
  3. Step 1: Create the module
  4. Step 2: Write the API
  5. Step 3: Run it on your machine
  6. Step 4: Push it to GitHub
  7. Step 5: Deploy it
  8. Step 6: Use it
  9. How fast does it start?
  10. Troubleshooting

To deploy a Go API with Gin on Light Cloud, keep go.mod and main.go at the root of the repository, listen on the port in PORT, push to GitHub and click Deploy. Light Cloud detects Gin from go.mod, compiles the app into one binary with go build and runs it in a small container. Set GIN_MODE=release in the console so Gin runs in production mode.

In my run a new instance was answering 0.27 seconds after it started.

What you will build

A tiny link shortener: POST /links stores a URL, GET /l/1 redirects to it, and GET /health answers {"status":"ok"}. It writes JSON logs the Logs tab understands and shuts down cleanly when Light Cloud replaces an instance.

Source code: github.com/light-cloud-com/tutorial-go-gin-api.

Before you start

Step 1: Create the module

terminal
$ mkdir tutorial-go-gin-api
$ cd tutorial-go-gin-api
$ go mod init github.com/YOUR-USERNAME/tutorial-go-gin-api
go: creating new go.mod: module github.com/YOUR-USERNAME/tutorial-go-gin-api
$ go get github.com/gin-gonic/gin@latest
go: added github.com/gin-gonic/gin v1.12.0

Output is trimmed; go get lists every module it adds. On Windows, mkdir also prints a short table describing the new folder.

Step 2: Write the API

Everything goes in one file:

main.go
go
package main

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"syscall"
	"time"

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

type Link struct {
	ID  int    `json:"id"`
	URL string `json:"url"`
}

type newLink struct {
	URL string `json:"url" binding:"required,url"`
}

// store keeps links in memory: fine for a demo, gone on every restart.
type store struct {
	mu    sync.Mutex
	links []Link
}

func (s *store) add(url string) Link {
	s.mu.Lock()
	defer s.mu.Unlock()
	link := Link{ID: len(s.links) + 1, URL: url}
	s.links = append(s.links, link)
	return link
}

func (s *store) get(id int) (Link, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if id < 1 || id > len(s.links) {
		return Link{}, false
	}
	return s.links[id-1], true
}

func main() {
	// JSON logs on stdout. The Logs tab reads "severity" and "message",
	// so rename slog's "level" and "msg" keys.
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			switch a.Key {
			case slog.LevelKey:
				a.Key = "severity"
			case slog.MessageKey:
				a.Key = "message"
			}
			return a
		},
	}))

	links := &store{}
	router := gin.New()
	router.Use(gin.Recovery())

	router.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	router.POST("/links", func(c *gin.Context) {
		var body newLink
		if err := c.ShouldBindJSON(&body); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "send {\"url\": \"https://...\"}"})
			return
		}
		link := links.add(body.URL)
		logger.Info("link created", "id", link.ID, "url", link.URL)
		c.JSON(http.StatusCreated, link)
	})

	// /l/3 redirects to the link with id 3.
	router.GET("/l/:id", func(c *gin.Context) {
		id, _ := strconv.Atoi(c.Param("id"))
		link, ok := links.get(id)
		if !ok {
			c.JSON(http.StatusNotFound, gin.H{"error": "link not found"})
			return
		}
		c.Redirect(http.StatusFound, link.URL)
	})

	// Light Cloud tells the app which port to use in PORT.
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	server := &http.Server{Addr: ":" + port, Handler: router}

	// Stop cleanly on SIGTERM, which Light Cloud sends to old instances.
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		logger.Info("listening", "port", port, "mode", gin.Mode())
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("server stopped", "error", err)
			os.Exit(1)
		}
	}()

	<-ctx.Done()
	logger.Info("shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = server.Shutdown(shutdownCtx)
}

Three production details are in there:

  1. The port comes from PORT. 8080 is only the fallback for your machine.
  2. Logs are JSON with severity and message. log/slog writes level and msg; ReplaceAttr renames them to the names the Logs tab reads, so each line gets the right level.
  3. SIGTERM stops the server gracefully. When a new version goes live, Light Cloud sends the old instance SIGTERM; server.Shutdown lets requests in flight finish.

Tidy the module so go.sum lists everything the build needs, and keep the compiled binary out of Git:

terminal
$ go mod tidy
.gitignore
text
/main
/tmp

Step 3: Run it on your machine

terminal
$ go run .
[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)
{"time":"2026-09-25T14:08:40.505932+02:00","severity":"INFO","message":"listening","port":"8080","mode":"debug"}

Output is trimmed; debug mode also lists the routes. Gin is telling you what to set in production, which you will do in Step 5. From a second terminal:

terminal
$ curl -X POST http://localhost:8080/links -H "content-type: application/json" -d '{"url":"https://light-cloud.com"}'
{"id":1,"url":"https://light-cloud.com"}
$ curl -X POST http://localhost:8080/links -H "content-type: application/json" -d '{"url":"not a url"}'
{"error":"send {\"url\": \"https://...\"}"}

Now open http://localhost:8080/l/1 in your browser: it takes you to light-cloud.com. The binding:"required,url" tag on the struct rejects anything that is not a URL, as the second request shows. Stop the server with Ctrl+C.

Step 4: Push it to GitHub

terminal
$ git init -b main
$ git add -A
$ git commit -m "Gin link shortener"
# Create an empty repository named tutorial-go-gin-api on github.com/new first.
$ git remote add origin https://github.com/YOUR-USERNAME/tutorial-go-gin-api.git
$ git push -u origin main
To https://github.com/YOUR-USERNAME/tutorial-go-gin-api.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

Step 5: Deploy it

  1. In the Light Cloud console, click New..., then Deploy from GitHub, and pick tutorial-go-gin-api. Light Cloud shows Gin / Backend, based on go.mod, and Runs on a server:

The detection banner Gin Backend based on go.mod, with Runs on a server highlighted

  1. Under Advanced, Port is 8080, the same fallback as in the code. Under Environment variables, click Add variable and add GIN_MODE with the value release:

The Environment variables section with GIN_MODE set to release

  1. Click Deploy and wait for Deployed; 2 minutes 42 seconds in my run.

The Production overview of tutorial-go-gin-api with the Deployed badge and the URL card highlighted

The first build takes longer than the ones after it. The build image has Go 1.25 with GOTOOLCHAIN=auto; my go.mod says go 1.27.1, so the build log shows go: downloading go1.27.1 (linux/amd64) before it compiles.

Step 6: Use it

terminal
$ curl -X POST https://main-tutorial-go-gin-api-yourworkspace.light-cloud.io/links -H "content-type: application/json" -d '{"url":"https://light-cloud.com"}'
{"id":1,"url":"https://light-cloud.com"}

Open https://main-tutorial-go-gin-api-yourworkspace.light-cloud.io/l/1 in your browser, and it takes you to light-cloud.com. Then open the Logs tab: the listening line shows "mode":"release" and each line has its level, because the JSON carries severity.

How fast does it start?

From the logs, the time from "Starting new instance" to the app's listening line:

AppInstance start to ready
Go (Gin), this guide0.26 to 0.27 s
NestJS, node dist/main5.4 s
NestJS, nest start17.2 s

A Go binary has no runtime to boot and nothing to compile, so it barely notices a cold start. That makes Min instances at 0, the cheapest setting, a comfortable choice for a Go API.

Troubleshooting

The build fails with missing go.sum entries

Run go mod tidy, commit go.sum and push again.

Every log line shows DEFAULT

The line is not JSON with a severity field. log.Println and the default slog JSON handler (which writes level) both show as DEFAULT; use the ReplaceAttr from Step 2.

More tutorials