GoCroot: Greatly Optimized Cloud Resource Operation and Orchestration Transformer

Unlock the Full Potential of Cost-Effective Cloud Infrastructure

Elevate your cloud operations with GoCroot, a lightweight Go backend skeleton for businesses and developers seeking high performance and cost-efficiency. GoCroot is built on the Go standard library (net/http), and one codebase runs everywhere: as a Google Cloud Run function (pay only when requests come in), as a single binary on a VPS or web hosting, or in a small Docker image.

πŸš€ Ready to scale efficiently? Start with GoCroot Core.


Ecosystem

Repository Purpose
gocroot/core The starting point for every project. Backend skeleton for Cloud Run functions, VPS, web hosting, and Docker
gocroot/mgdb MongoDB helper (connect, CRUD, generic queries)
gocroot/jsonapi JSON API HTTP helper
whatsauth/watoken PASETO token encode/decode, used for login

One Core, Every Hosting

GoCroot Core is the only template you need. The same code has two entry points:

Hosting Entry point How
Google Cloud Run functions (recommended for cost) gcf.go β†’ WebHook gcloud functions deploy, see Deployment
VPS run/main.go Static binary + systemd, see the deployment guide
Web hosting (Apache .htaccess proxy) run/main.go Static binary, see the deployment guide
Docker run/main.go ~10 MB scratch image (binary + CA certificates), see the deployment guide
Local development run/main.go go run ./run

The binary never includes the Cloud Functions framework. It reads PORT from the environment and shuts down gracefully on SIGTERM (docker stop, systemctl restart).

The older templates (gocroot/gcp and the Fiber-based gocroot/gocroot / gocroot/alwaysdata) are no longer needed: core covers all of these platforms. Start new projects from core.


Quick Start

Requirements: Go 1.27 or newer.

1. Create your project from the template

gocroot/core is a template repository. Click Use this template β†’ Create a new repository on GitHub, then clone your new repository:

git clone https://github.com/username/myapi.git
cd myapi

Or with the GitHub CLI: gh repo create myapi --template gocroot/core --private --clone.

2. Rename the Go module

Core is a skeleton you own and modify, not a library you import. Change the module path from github.com/gocroot/core to your own, so your imports become github.com/username/myapi/controller, github.com/username/myapi/models, and so on:

go mod edit -module github.com/username/myapi
grep -rl 'github.com/gocroot/core' --include='*.go' . | xargs sed -i 's#github.com/gocroot/core#github.com/username/myapi#g'
go build ./...

On macOS use sed -i '' instead of sed -i. On Windows (PowerShell):

go mod edit -module github.com/username/myapi
Get-ChildItem -Recurse -Filter *.go | ForEach-Object {
    (Get-Content $_.FullName -Raw) -replace 'github.com/gocroot/core', 'github.com/username/myapi' | Set-Content $_.FullName -NoNewline
}
go build ./...

Your code still calls controller.Login, models.LoginAuth, config.PublicKey. Only the import lines change.

3. Run it

export PRIVATEKEY=... PUBLICKEY=... MONGOSTRING=...
export DEMO_USER=admin DEMO_PASSWORD=choose-a-password   # optional, enables the demo login
go run ./run          # http://localhost:3000

On Windows (PowerShell): go run .\run\main.go.

To generate a PRIVATEKEY/PUBLICKEY pair, clone whatsauth/watoken and run go test.

Try it:

curl http://localhost:3000/
curl -X POST http://localhost:3000/login -d '{"username":"admin","password":"choose-a-password"}'
curl http://localhost:3000/login -H "Token: <token from login>"

The demo login is off by default: without DEMO_USER and DEMO_PASSWORD it answers 503. Don’t set them in production. Replace controller.Login with a real user lookup instead. An invalid or expired token answers 401, so the frontend knows to ask the user to log in again.


Project Structure

core/
β”œβ”€β”€ gcf.go           # Cloud function entry point: functions.HTTP("WebHook", routes.HandleRoutes)
β”œβ”€β”€ run/main.go      # Local server on config.Port (:3000)
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ config.go    # SetEnv(): reads PORT, MONGOSTRING, PRIVATEKEY, PUBLICKEY, ALLOWED_ORIGINS
β”‚   └── cors.go      # Allowed origins and preflight handling
β”œβ”€β”€ routes/routes.go # HandleRoutes: switch on method, then on path
β”œβ”€β”€ controller/      # HTTP handlers
└── models/          # Request/response/document structs

Request flow: gcf.go or run/main.go β†’ routes.HandleRoutes β†’ CORS check β†’ controller.*.


Type Declaration

Data structures are Go structs. Here’s an example:

type Example struct {
	ID       primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"`
	Messages string             `json:"messages" bson:"messages"`
}

Explanation of Tags:

Query parameters, path parameters, and headers are read in the controller, see Common operations.

Quick inline struct:

var request struct {
	Token string `json:"token"`
}

Routing

Routes live in routes/routes.go as a plain switch on method, then on path. It is the fastest option on Cloud Run functions (benchmarked against http.ServeMux) and needs no router dependency. CORS runs first and answers preflight requests.

Routes that ship with core

func HandleRoutes(w http.ResponseWriter, r *http.Request) {
	if config.SetAccessControlHeaders(w, r) {
		return // preflight (OPTIONS) already answered
	}

	switch r.Method {
	case http.MethodGet:
		switch r.URL.Path {
		case "/":
			controller.GetHome(w, r)
		case "/login":
			controller.GetUserName(w, r)
		default:
			controller.NotFound(w, r)
		}

	case http.MethodPost:
		switch r.URL.Path {
		case "/login":
			controller.Login(w, r)
		default:
			controller.NotFound(w, r)
		}

	default:
		controller.MethodNotAllowed(w, r)
	}
}
Request Response
GET / 200 {"status":"success","message":"Welcome"}
POST /login 200 with token (demo login, needs DEMO_USER/DEMO_PASSWORD), 404 wrong credentials, 503 demo login off
GET /login with header Token 200 with username, 401 invalid or expired token
GET/POST to an unknown path 404 JSON
Any other method (PUT, DELETE, HEAD, …) on any path 405 JSON
OPTIONS from an allowed origin 204 CORS preflight

Adding routes

  1. Static path: add a case under the right method.
  2. Path parameter (/data/user/{id}): put it in default, after the static paths, using controller.PathParam.
  3. New method (PUT, DELETE): add a case http.MethodPut: block with its own default: controller.NotFound(w, r). PUT and DELETE are already allowed for CORS. Any other method or custom header must be added to Access-Control-Allow-Methods / Access-Control-Allow-Headers in config/cors.go.
	case http.MethodGet:
		switch r.URL.Path {
		case "/":
			controller.GetHome(w, r)
		case "/login":
			controller.GetUserName(w, r)
		case "/data/user/me": // static paths first, so "me" is never read as an id
			controller.GetMyProfile(w, r)
		default:
			if _, ok := controller.PathParam(r.URL.Path, "/data/user/"); ok {
				controller.GetUserByID(w, r) // /data/user/{id}
				return
			}
			controller.NotFound(w, r)
		}

	case http.MethodPut:
		if _, ok := controller.PathParam(r.URL.Path, "/data/user/"); ok {
			controller.UpdateUser(w, r) // PUT /data/user/{id}
			return
		}
		controller.NotFound(w, r)

Read the parameter in the controller with the same prefix, so routing and reading can never disagree:

func GetUserByID(w http.ResponseWriter, r *http.Request) {
	id, ok := PathParam(r.URL.Path, "/data/user/")
	if !ok {
		NotFound(w, r)
		return
	}
	// ... look up id
}

controller.PathParam(path, prefix) returns the single path segment after prefix and true, or false if the path doesn’t start with prefix, the segment is empty, or it contains another / (so /data/user/1/x is a 404). It runs in a few nanoseconds without allocating. For more than one value, use query parameters (/data/order?user=1&status=paid, read with r.URL.Query().Get) or a dedicated prefix per resource.

CORS origins

Set the frontend domains with the ALLOWED_ORIGINS environment variable, comma separated, for example https://app.example.com,https://admin.example.com. Without it, the default list in config/cors.go is used. Requests without an Origin header (server-to-server, curl) are always processed, they just get no CORS headers.


Controller

A controller is a standard net/http handler:

func GetHome(w http.ResponseWriter, r *http.Request) {
	WriteJSON(w, http.StatusOK, models.Response{Status: "success", Message: "Welcome"})
}

Core ships two helpers in controller/controller.go:

// WriteJSON sets Content-Type, writes the status code, then encodes the body.
func WriteJSON(w http.ResponseWriter, status int, body any)

// DecodeJSON reads a JSON body into dst, limited to 1 MiB.
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error

Use them instead of building JSON with fmt.Sprintf, so values are escaped correctly and oversized bodies are rejected.

Core’s models.Response has status, message, token, and username. For richer error details, extend it in models/:

type Response struct {
	Status   string `json:"status"`
	Message  string `json:"message"`
	Token    string `json:"token,omitempty"`
	Username string `json:"username,omitempty"`
	// tambahan untuk detail error
	Info     string `json:"info,omitempty"`
	Location string `json:"location,omitempty"`
	Response string `json:"response,omitempty"`
}

Common operations

Return JSON Response:

WriteJSON(w, http.StatusOK, Example)

Authentication with Token

Login issues a PASETO token, and protected endpoints read it from the Token header:

// login: issue a token for the user id
token, err := watoken.Encode(login.UserName, config.PrivateKey)

// protected endpoint: validate the token
userID, err := watoken.DecodeGetId(config.PublicKey, r.Header.Get("Token"))
if err != nil {
	WriteJSON(w, http.StatusUnauthorized, models.Response{Status: "Unauthorized", Message: "invalid or expired token"})
	return
}

An invalid or expired token is answered with 401, so the frontend knows the user must log in again.

Why PASETO, not JWT

GoCroot uses PASETO v4.public (Ed25519) through watoken, not JWT:

Like any stateless token, a PASETO token cannot be revoked before it expires. Keep the lifetime short (watoken.Encode gives 2 hours). If you need forced logout, add a denylist instead of making tokens last longer.

Keep PRIVATEKEY only on the service that issues tokens (login). Services that only check tokens need PUBLICKEY alone.


MongoDB with mgdb

go get github.com/gocroot/mgdb

Connect once in config.SetEnv() and keep the database handle in a global variable:

var (
	MongoDB  *mongo.Database
	MongoErr error
)

func SetEnv() {
	once.Do(func() {
		// ...
		MongoString = os.Getenv("MONGOSTRING")
		MongoDB, MongoErr = mgdb.MongoConnect(mgdb.DBInfo{
			DBString: MongoString,
			DBName:   "mydatabase",
		})
	})
}

Make sure MONGOSTRING is set to a valid mongodb:// or mongodb+srv:// URI. An empty or malformed value can make the app crash at startup.

Don’t call log.Fatal or panic when the connection fails. On Cloud Functions that kills the container at startup and the deploy fails with Container Healthcheck failed. Keep the error and answer 503 from endpoints that need the database:

if config.MongoErr != nil {
	WriteJSON(w, http.StatusServiceUnavailable, models.Response{Status: "Error", Message: "Database unavailable"})
	return
}

Then use the generic helpers in controllers:

// get one document by ObjectID
objectID, err := primitive.ObjectIDFromHex(id)
user, err := mgdb.GetOneDoc[models.User](config.MongoDB, "user", bson.M{"_id": objectID})

// get all documents
users, err := mgdb.GetAllDoc[[]models.User](config.MongoDB, "user", bson.M{})

// insert
insertedID, err := mgdb.InsertOneDoc(config.MongoDB, "user", newUser)

// update
_, err = mgdb.UpdateOneDoc(config.MongoDB, "user", bson.M{"_id": objectID}, bson.M{"name": "New Name"})

ObjectID conversion

objectID, err := primitive.ObjectIDFromHex(strID) // string β†’ ObjectID
strID := objectID.Hex()                            // ObjectID β†’ string

Insert without a struct

doc := bson.D{
	{Key: "username", Value: "user1"},
	{Key: "createdAt", Value: time.Now()},
}

See the mgdb README for all helpers.


Testing and Code Coverage

  1. Write test cases in *_test.go files:
    func TestSum(t *testing.T) {
    	if got := Sum(2, 3); got != 5 {
    		t.Errorf("expected 5, got %d", got)
    	}
    }
    
  2. Run checks and tests:
    go build ./... && go vet ./... && go test -cover ./...
    
  3. Generate an HTML coverage report:
    go test -coverprofile=coverage.out ./...
    go tool cover -html=coverage.out
    

Deployment

Free and low-cost hosting

Ready-to-copy GitHub Actions workflows (test β†’ build β†’ deploy on every push to main) are in the deployment guide. Status as of November 2026, free tiers change often:

Hosting Cost Sleeps when idle? CI/CD
Google Cloud Run functions Free up to 2M requests/month (billing account required) Scales to zero core workflow
alwaysdata Free plan: 1 GB disk, 256 MB RAM, 1/4 CPU No workflow
Render Free 750 instance hours/month After 15 min idle (~1 min to wake) render.yaml
Oracle Cloud Always Free / any VPS Free VMs (AMD micro 1 GB, or Ampere up to 2 OCPU / 12 GB) No workflow
Fly.io Not free for new accounts, ~$2.19/month for 256 MB Optional auto stop workflow

Core reads PORT and IP from the environment, so the same binary works on all of them without code changes.

Publishing a module version

Set up your Go environment:

export GOPROXY=proxy.golang.org

Update dependencies and publish packages:

go get -u all
go mod tidy
git tag v0.1.0
git push origin --tags