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.
net/http and a few small helper packages.π Ready to scale efficiently? Start with GoCroot Core.
| 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 |
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.
Requirements: Go 1.27 or newer.
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.
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.
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_USERandDEMO_PASSWORDit answers503. Donβt set them in production. Replacecontroller.Loginwith a real user lookup instead. An invalid or expired token answers401, so the frontend knows to ask the user to log in again.
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.*.
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"`
}
json: field name in the JSON payload of the REST API.bson: field name in MongoDB.Query parameters, path parameters, and headers are read in the controller, see Common operations.
Quick inline struct:
var request struct {
Token string `json:"token"`
}
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.
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 |
case under the right method./data/user/{id}): put it in default, after the static paths, using controller.PathParam.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.
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.
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"`
}
userID, err := watoken.DecodeGetId(config.PublicKey, r.Header.Get("Token"))
user, err := mgdb.GetOneDoc[models.User](config.MongoDB, "user", bson.M{"phonenumber": userID})
Furthermore, to protect every endpoint with authentication:
var respn models.Response
userID, err := watoken.DecodeGetId(config.PublicKey, r.Header.Get("Token"))
if err != nil {
respn.Status = "Error : Token Tidak Valid"
respn.Location = "Decode Token Error"
respn.Response = err.Error()
WriteJSON(w, http.StatusUnauthorized, respn)
return
}
_, err = mgdb.GetOneDoc[models.User](config.MongoDB, "user", bson.M{"phonenumber": userID})
if err != nil {
respn.Status = "Error : User tidak ada di database"
respn.Location = userID
WriteJSON(w, http.StatusNotFound, respn)
return
}
var login models.LoginAuth
err := DecodeJSON(w, r, &login)
// or a list
var tasklists []models.TaskList
err = DecodeJSON(w, r, &tasklists)
token := r.Header.Get("Token")
//or
secret := r.Header.Get("Secret")
/data/pelanggan/one/{id}):
id, ok := PathParam(r.URL.Path, "/data/pelanggan/one/")
if !ok {
NotFound(w, r)
return
}
Furthermore, with get data logic using MongoDB object ID:
// get param
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
respn.Status = "Error : object ID tidak valid"
respn.Response = err.Error()
WriteJSON(w, http.StatusPreconditionFailed, respn)
return
}
// get database
datapelanggan, err := mgdb.GetOneDoc[models.DataPelanggan](config.MongoDB, "pelanggan", bson.M{"_id": objectID})
if err != nil {
respn.Status = "Error : data tidak ditemukan"
respn.Response = err.Error()
WriteJSON(w, http.StatusExpectationFailed, respn)
return
}
WriteJSON(w, http.StatusOK, datapelanggan)
/data?id=123):
id := r.URL.Query().Get("id")
file, header, err := r.FormFile("image")
Return JSON Response:
WriteJSON(w, http.StatusOK, Example)
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.
GoCroot uses PASETO v4.public (Ed25519) through watoken, not JWT:
alg header to tamper with. A JWT tells the server which algorithm to verify with, and that design led to well-known attacks: alg: none tokens accepted without a signature, and RS256/HS256 confusion where the public key is used as an HMAC secret. A PASETO token has a fixed version and purpose (v4.public), so there is nothing for an attacker to choose.PRIVATEKEY and checked with PUBLICKEY, so any service that holds the public key can verify a token on its own. This comes from asymmetric signing (JWT with RS256/EdDSA can do the same), and PASETO makes it the only option.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.
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
MONGOSTRINGis set to a validmongodb://ormongodb+srv://URI. An empty or malformed value can make the app crash at startup.Donβt call
log.Fatalorpanicwhen the connection fails. On Cloud Functions that kills the container at startup and the deploy fails with Container Healthcheck failed. Keep the error and answer503from 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, err := primitive.ObjectIDFromHex(strID) // string β ObjectID
strID := objectID.Hex() // ObjectID β string
doc := bson.D{
{Key: "username", Value: "user1"},
{Key: "createdAt", Value: time.Now()},
}
See the mgdb README for all helpers.
*_test.go files:
func TestSum(t *testing.T) {
if got := Sum(2, 3); got != 5 {
t.Errorf("expected 5, got %d", got)
}
}
go build ./... && go vet ./... && go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
WebHook, runtime go127. A ready-to-copy GitHub Actions workflow is in the core README. Check the runtime schedule regularly, because older Go runtimes are deprecated.gcf-artifacts after every deploy to keep storage near zero. See the free tier limits.cloudflared tunnel --url 127.0.0.1:3000../run, see the deployment guide.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.
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