> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Deploy a gRPC service in Go

> Deploy a grpc-go service to Sevalla: gRPC on the application's domain over TLS, calls from another application over an internal connection, health checking, reflection and a readiness probe.

A gRPC service written with [grpc-go](https://github.com/grpc/grpc-go) runs on Sevalla as a Go application: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) compiles it into a static binary, and the web process serves gRPC as cleartext HTTP/2 (h2c) on the port in the `PORT` environment variable. With the web process's protocol set to **gRPC**, Cloudflare and Sevalla's proxy terminate TLS, so clients call the service on the application's domain on port 443. Other applications in the same location call it over an [internal connection](https://docs.sevalla.com/applications/networking.md#internal-connections), without TLS.

This guide builds `catalog`, a gRPC service with one method, and `storefront`, a JSON API that calls it over the internal connection. Both come from one Go module and one repository, and run as two applications.

## At a glance

| | |
| - | - |
| Source | A Git repository with both programs: `catalog` at the module's root and `storefront` in `cmd/storefront` |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Go 1.27), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `catalog`: `/usr/local/bin/catalog`, the binary Ingot builds from the root package. `storefront`: the custom start command `exec /usr/local/bin/storefront` |
| Port | The `PORT` environment variable, `8080` by default. `catalog` also answers the readiness probe over HTTP on port `8081` |
| Processes | One web process in each application. `catalog`'s web process uses the **gRPC** protocol |
| Services | `storefront` reaches `catalog` over an internal connection |
| Disk | None |
| Health check | `catalog`: `grpc.health.v1.Health/Check` on the gRPC port, and `GET /healthz` on port `8081` returns `200` (readiness probe) |
| Size | **H1** for each web process |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `CATALOG_ADDR` | Yes, in `storefront` | Runtime | No | `catalog`'s internal hostname and port, for example `catalog-x7k2m-web.catalog-x7k2m.svc.cluster.local:8080` |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* Go 1.27 on your computer.
* The Protocol Buffers compiler, `protoc`, for example with `brew install protobuf`, and the two Go plugins for it:

  ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
  go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
  ```

  `go install` puts them in `$(go env GOPATH)/bin`, which must be on your `PATH`.
* [grpcurl](https://github.com/fullstorydev/grpcurl), to call the service from a terminal: `go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest`, or `brew install grpcurl`.

## Create the project

Create a folder and a Go module, and add grpc-go and the Protocol Buffers runtime. The last part of the module path, `catalog`, becomes the name of the server's binary:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir catalog && cd catalog
go mod init github.com/acme/catalog
go get google.golang.org/grpc google.golang.org/protobuf
```

`go get` installs grpc-go 1.85 and protobuf 1.36. Describe the service in a `.proto` file:

```protobuf proto/catalog.proto theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
syntax = "proto3";

package acme.catalog.v1;

option go_package = "github.com/acme/catalog/catalogpb";

service CatalogService {
  rpc GetProduct(GetProductRequest) returns (Product);
}

message GetProductRequest {
  int32 id = 1;
}

message Product {
  int32 id = 1;
  string name = 2;
  double price = 3;
}
```

Generate the Go code for the messages and the service into `catalogpb`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
protoc --go_out=. --go_opt=module=github.com/acme/catalog \
  --go-grpc_out=. --go-grpc_opt=module=github.com/acme/catalog \
  proto/catalog.proto
```

`protoc` writes `catalogpb/catalog.pb.go` and `catalogpb/catalog_grpc.pb.go`. Commit them: Ingot builds the Go code in the repository and doesn't run `protoc`. Run the command again after every change to the `.proto` file.

Create the server at the root of the module:

```go main.go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"context"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/health"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/reflection"
	"google.golang.org/grpc/status"

	"github.com/acme/catalog/catalogpb"
)

var products = map[int32]*catalogpb.Product{
	1: {Id: 1, Name: "Espresso beans", Price: 14.5},
	2: {Id: 2, Name: "Pour-over kettle", Price: 39},
}

type catalogServer struct {
	catalogpb.UnimplementedCatalogServiceServer
}

func (catalogServer) GetProduct(_ context.Context, req *catalogpb.GetProductRequest) (*catalogpb.Product, error) {
	if p, ok := products[req.GetId()]; ok {
		return p, nil
	}
	return nil, status.Errorf(codes.NotFound, "product %d not found", req.GetId())
}

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	lis, err := net.Listen("tcp", ":"+port)
	if err != nil {
		log.Fatal(err)
	}

	srv := grpc.NewServer()
	catalogpb.RegisterCatalogServiceServer(srv, catalogServer{})
	healthSrv := health.NewServer()
	healthpb.RegisterHealthServer(srv, healthSrv)
	reflection.Register(srv)

	// Sevalla's readiness probe sends HTTP/1.1 GET requests, which a gRPC port can't answer,
	// so a second port reports the gRPC health status over HTTP.
	go func() {
		probe := &http.Server{
			Addr:              ":8081",
			ReadHeaderTimeout: 5 * time.Second,
			Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				res, err := healthSrv.Check(r.Context(), &healthpb.HealthCheckRequest{})
				if err != nil || res.GetStatus() != healthpb.HealthCheckResponse_SERVING {
					http.Error(w, "not serving", http.StatusServiceUnavailable)
					return
				}
				w.Write([]byte("ok"))
			}),
		}
		log.Fatal(probe.ListenAndServe())
	}()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		log.Printf("Listening on port %s", port)
		if err := srv.Serve(lis); err != nil {
			log.Fatal(err)
		}
	}()

	<-ctx.Done()
	log.Print("Shutting down")
	healthSrv.Shutdown()
	stopped := make(chan struct{})
	go func() {
		srv.GracefulStop()
		close(stopped)
	}()
	select {
	case <-stopped:
	case <-time.After(20 * time.Second):
		srv.Stop()
	}
	log.Print("Server stopped")
}
```

* `grpc.NewServer()` and `srv.Serve(lis)` serve gRPC over cleartext HTTP/2 on [`PORT`](https://docs.sevalla.com/guides/go/overview#how-go-applications-run-on-sevalla), which Sevalla sets to the web process's port (`8080` by default). On your computer, the server uses port 8080.
* A method returns a gRPC status for errors, such as `codes.NotFound`, and the client receives it as is.
* The health server and reflection serve clients and tools such as grpcurl. See [Check health and use reflection](#check-health-and-use-reflection).
* The HTTP server on port `8081` answers the [readiness probe](#check-health-and-use-reflection) with the health server's status.
* On `SIGTERM`, the server stops gracefully. See [Shut down gracefully](#shut-down-gracefully).

Create `storefront`, the JSON API that calls `catalog`, in `cmd/storefront`:

```go cmd/storefront/main.go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"

	"github.com/acme/catalog/catalogpb"
)

func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.NewEncoder(w).Encode(v)
}

func main() {
	// CATALOG_ADDR is the catalog's internal hostname and port, such as
	// catalog-x7k2m-web.catalog-x7k2m.svc.cluster.local:8080
	conn, err := grpc.NewClient(os.Getenv("CATALOG_ADDR"), grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	catalog := catalogpb.NewCatalogServiceClient(conn)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/products/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.Atoi(r.PathValue("id"))
		ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
		defer cancel()
		p, err := catalog.GetProduct(ctx, &catalogpb.GetProductRequest{Id: int32(id)})
		switch {
		case status.Code(err) == codes.NotFound:
			writeJSON(w, http.StatusNotFound, map[string]string{"error": status.Convert(err).Message()})
		case err != nil:
			log.Printf("catalog: %v", err)
			writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "catalog unavailable"})
		default:
			writeJSON(w, http.StatusOK, map[string]any{"id": p.GetId(), "name": p.GetName(), "price": p.GetPrice()})
		}
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	srv := &http.Server{Addr: ":" + port, Handler: mux, ReadHeaderTimeout: 10 * time.Second}

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		log.Printf("Listening on port %s", port)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	<-ctx.Done()
	log.Print("Shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Fatal(err)
	}
	log.Print("Server stopped")
}
```

`storefront` connects without TLS (`insecure.NewCredentials()`): traffic over an internal connection stays on Sevalla's private network, where the web process serves cleartext HTTP/2. A `NotFound` from `catalog` becomes a `404`, and any other error a `503`.

Record the dependencies, and keep the binaries that `go build` writes out of the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
go mod tidy
printf '/catalog\n/storefront\n' > .gitignore
```

## Prepare the project for Sevalla

The project needs no build configuration:

| File | Why Sevalla needs it |
| - | - |
| `go.mod` | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) recognizes a Go module by it. Its `go` line, such as `go 1.27.1`, is the oldest Go the module builds with, and Ingot builds on Go 1.27. See [Pin the Go version](https://docs.sevalla.com/guides/go/overview.md#pin-the-go-version). |
| `go.sum` | The checksums of every module the build downloads. Commit it. |
| `catalogpb/*.pb.go` | The generated code. Ingot compiles it with the rest of the module; it doesn't run `protoc`. |
| `main.go` | The `main` package at the root of the module. Ingot compiles it into `/usr/local/bin/catalog` and starts the web process with that binary. |
| `cmd/storefront/main.go` | A second `main` package. Ingot compiles it into `/usr/local/bin/storefront` in the same image, and the `storefront` application starts it with a custom start command. |

Ingot builds both programs with one command, `go build -trimpath -ldflags="-s -w" -o /usr/local/bin/ . ./cmd/storefront`, with `CGO_ENABLED=0`, because nothing in the module imports `"C"`.

Run `go run .` in one terminal, then call the server from another with grpcurl:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
grpcurl -plaintext -d '{"id": 2}' localhost:8080 acme.catalog.v1.CatalogService/GetProduct
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "id": 2,
  "name": "Pour-over kettle",
  "price": 39
}
```

`-plaintext` connects without TLS, as the server listens on your computer. Then commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git init
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/catalog.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile).

**1. Create the application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**. Under **Location**, choose a **Data center**. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**2. Switch the protocol to gRPC**

Click the **Networking** tab. In **Expose HTTP traffic**, click **Update protocol**, choose **gRPC** in **Protocol**, then click **Update protocol**. The section's title changes to **Expose gRPC traffic**.

**3. Turn on the readiness probe**

Click the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Under **Health checks**, turn on **Readiness probe**, set the **Path** to `/healthz` and the **Port** to `8081`, then click **Update process**.

**4. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

**5. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

With the **gRPC** protocol, Sevalla's proxy forwards requests from the application's domains to the web process as cleartext HTTP/2. The change applies to the running application within about 10 seconds, without a deployment. With the default **HTTP** protocol, the proxy speaks HTTP/1.1 to the web process, and every gRPC call through the domain fails. See [Use gRPC](https://docs.sevalla.com/applications/networking.md#use-grpc).

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Go 1.27 from the `go` line of `go.mod`, and the start command from the `main` package at the root.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Go 1.27 application ───────────────────────────────────╮
   │ Go       1.27                    go.mod "go 1.27.1"       │
   │ Start    /usr/local/bin/catalog  main package at the root │
   ├─ Image ───────────────────────────────────────────────────┤
   │ Build    golang:1.27-trixie                               │
   │ Runtime  debian:trixie-slim                               │
   │ User     1000:1000                                        │
   │ Init     tini                                             │
   │ Port     $PORT, default 8080                              │
   ╰──────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**, and its [runtime logs](https://docs.sevalla.com/applications/logs.md) show `Listening on port 8080`.

3. Call the service on the application's domain, for example `catalog-x7k2m.sevalla.app`, on port 443. grpcurl connects with TLS by default:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   grpcurl -d '{"id": 2}' catalog-x7k2m.sevalla.app:443 acme.catalog.v1.CatalogService/GetProduct
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {
     "id": 2,
     "name": "Pour-over kettle",
     "price": 39
   }
   ```

   A product that doesn't exist returns the gRPC status the method sent, and grpcurl exits with code `69`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   grpcurl -d '{"id": 9}' catalog-x7k2m.sevalla.app:443 acme.catalog.v1.CatalogService/GetProduct
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ERROR:
     Code: NotFound
     Message: product 9 not found
   ```

If the deployment fails, read its build log, then see [Troubleshoot](#troubleshoot). To check the deployment from a terminal or an AI agent, read its `status` with [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) and its build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`), as in [Deploy with the API](#deploy-with-the-api).

## Check health and use reflection

The server registers two standard gRPC services next to `CatalogService`:

* **Health checking** (`grpc.health.v1.Health`): `health.NewServer()` reports `SERVING` for the whole server, the empty service name. Load balancers, clients and grpcurl read it with `Check`, or follow it with the streaming `Watch` method.
* **Reflection** (`grpc.reflection.v1.ServerReflection`): clients ask the server for its services and message types, so grpcurl needs no `.proto` file. Reflection publishes your API's schema to anyone who can reach the domain; leave out `reflection.Register` if you don't want that.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
grpcurl catalog-x7k2m.sevalla.app:443 grpc.health.v1.Health/Check
grpcurl catalog-x7k2m.sevalla.app:443 list
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "status": "SERVING"
}
acme.catalog.v1.CatalogService
grpc.health.v1.Health
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
```

Sevalla's readiness and liveness probes send HTTP/1.1 `GET` requests, and a gRPC port doesn't answer them: a gRPC server reads the request as a broken HTTP/2 connection and closes it, and `curl` reports `Received HTTP/0.9 when not allowed`. So the server answers the probe on a second port, `8081`, with the health server's status: `200` and `ok` while it's `SERVING`, `503` once the server shuts down. The probe's **Port** can be any port the process listens on; it doesn't need a [private port](#use-a-private-port). Each new instance gets traffic only after it answers the probe.

[Get process](https://api-docs.sevalla.com/v3/applications/get-process) (`getProcessV3`) returns the probe and the protocol:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -c '{ingress_protocol, readiness_probe}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"ingress_protocol":"grpc","readiness_probe":{"httpGet":{"path":"/healthz","port":8081,"scheme":"HTTP"},"initialDelaySeconds":0,"periodSeconds":10,"timeoutSeconds":1,"successThreshold":1,"failureThreshold":3}}
```

## What works through the proxy

Every request to the application's domain passes through Cloudflare and Sevalla's proxy before it reaches the web process. Tested with grpc-go 1.85 and grpcurl, with grpc-go's test service for the streaming rows:

| What | Through the domain, protocol **gRPC** |
| - | - |
| Unary calls over TLS on port 443 | Work |
| gRPC status codes and messages, such as `NotFound` | Reach the client unchanged |
| Server reflection and health checking | Work |
| Server, client and bidirectional streaming | Work. A server stream that sent a message every 10 seconds ran for 160 seconds |
| A stream with no message for 60 seconds | Reset, and the client gets `stream terminated by RST_STREAM with error code: INTERNAL_ERROR`. An idle `grpc.health.v1.Health/Watch` call ends this way after 60 seconds. Send a message at least every 60 seconds, or reconnect when the error arrives |
| Plain-text gRPC (`-plaintext`) to the domain | Doesn't work: grpcurl reports `Failed to dial target host`. The domain serves gRPC over TLS on port 443 only |
| HTTP/1.1 requests, such as `curl` or a browser | Get `415` with the header `grpc-message: invalid gRPC request content-type ""` from the server |
| gRPC with the protocol left at **HTTP** | Fails with `upstream connect error or disconnect/reset before headers. reset reason: protocol error` |
| gRPC readiness or liveness probes | Not available: probes are HTTP `GET` only. Use an HTTP port as in [Check health and use reflection](#check-health-and-use-reflection) |

Responses carry the headers that Sevalla and Cloudflare add, such as `server: cloudflare` and `cf-ray`. See [Request headers](https://docs.sevalla.com/applications/http-headers.md).

## Call the service from another application

Another application in the same location calls `catalog` on its internal hostname, on the web process's port, over an [internal connection](https://docs.sevalla.com/applications/networking.md#internal-connections). The traffic stays on Sevalla's private network and doesn't pass through Cloudflare, so the client connects without TLS. The web process's protocol doesn't matter for internal calls: they work with **HTTP** or **gRPC**, and with HTTP traffic turned off.

`storefront` is in the same repository. Its image has both binaries, and a custom start command runs `storefront`:

**1. Copy the internal hostname**

In `catalog`, click the **Networking** tab. In **Internal hostnames**, copy the web process's hostname, for example `catalog-x7k2m-web.catalog-x7k2m.svc.cluster.local`.

**2. Create the second application**

Create an application from the same repository and branch, in the same location as `catalog`, and name it `storefront`. Keep the **Size** at **H1**.

**3. Set the start command**

In `storefront`, click the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Enter `exec /usr/local/bin/storefront` as the **Custom start command**, then click **Update process**. `exec` makes the binary the container's main process, so it receives `SIGTERM`.

**4. Connect the applications**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**, choose `catalog` as the **Service**, turn off **Add environment variables to the application**, then click **Add internal connection**.

**5. Add the address**

Click the **Environment variables** tab, then click **Add environment variables**. Add `CATALOG_ADDR` with the hostname you copied, followed by `:8080`, for example `catalog-x7k2m-web.catalog-x7k2m.svc.cluster.local:8080`, and click **Add environment variables**.

**6. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

When the deployment's status is **Deployed**, `storefront` answers on its own domain with the product from `catalog`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-x7k2m.sevalla.app/api/products/2
curl -s -w '%{http_code}\n' https://storefront-x7k2m.sevalla.app/api/products/9
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"id":2,"name":"Pour-over kettle","price":39}
{"error":"product 9 not found"}
404
```

To keep `catalog` off the internet and reachable only by your applications, turn its HTTP traffic off: on its **Networking** tab, in **Expose gRPC traffic**, click **Disable**, then **Disable gRPC**. Its domain then answers `404`, and `storefront` keeps working. See [Turn HTTP traffic off](https://docs.sevalla.com/applications/networking.md#turn-http-traffic-off).

### Use a private port

Over an internal connection, the web process's port needs no setup. Any other port a process listens on, such as a second gRPC listener next to an HTTP server on the web process's port, must be a [private port](https://docs.sevalla.com/applications/networking.md#private-ports) first. Without it, every call fails with `connection error: desc = "error reading server preface: connection reset by peer"`.

To serve the same gRPC server on a second port, add this before the `go func()` that calls `srv.Serve(lis)` in `main.go`, and set the `GRPC_PRIVATE_PORT` environment variable to the port, such as `50051`:

```go main.go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
	if p := os.Getenv("GRPC_PRIVATE_PORT"); p != "" {
		privateLis, err := net.Listen("tcp", ":"+p)
		if err != nil {
			log.Fatal(err)
		}
		go func() {
			log.Printf("Listening on private port %s", p)
			if err := srv.Serve(privateLis); err != nil {
				log.Fatal(err)
			}
		}()
	}
```

Deploy, then expose the port: on `catalog`'s **Networking** tab, in **Private ports**, click **Expose private port**, choose the web process as the **Process**, enter `50051` as the **Port**, and click **Expose port**. Sevalla replaces the process's instances to apply it, without a deployment, and the port answers about 20 seconds later. Point `CATALOG_ADDR` in `storefront` at the new port, for example `catalog-x7k2m-web.catalog-x7k2m.svc.cluster.local:50051`, and deploy `storefront`.

With the API, [Create private port](https://api-docs.sevalla.com/v3/applications/create-private-port) (`createApplicationPrivatePortV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST https://api.sevalla.com/v3/applications/$APP_ID/private-ports \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"process_id":"'"$WEB_ID"'","port":50051}' | jq -c '{port, status}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"port":50051,"status":"creating"}
```

CLI: `sevalla apps private-ports create $APP_ID --process-id $WEB_ID --port 50051`. MCP: `sevalla_api_create` with `createApplicationPrivatePortV3`.

## Shut down gracefully

When Sevalla stops an instance, during a deployment, a restart or a scale-down, it waits 5 seconds, sends `SIGTERM`, and stops the instance if it's still running 30 seconds after the shutdown began. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

On `SIGTERM`, `catalog` sets its health status to `NOT_SERVING`, then `srv.GracefulStop()` stops accepting connections and waits for the calls in progress. If they take longer than 20 seconds, `srv.Stop()` closes them. `storefront` stops its HTTP server the same way. On every deployment, the old instance's [runtime logs](https://docs.sevalla.com/applications/logs.md) end with `Shutting down` and `Server stopped`. Ingot's image runs `catalog` under `tini`, which passes the signal on; for `storefront`, the `exec` in the start command does the same.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Both applications build from the same `Dockerfile` and `.dockerignore` at the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM golang:1.27-trixie AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ . ./cmd/storefront

FROM alpine:3.24
RUN adduser -D -u 1000 app
COPY --from=build /out/ /usr/local/bin/
USER 1000:1000
EXPOSE 8080
CMD ["catalog"]
```

| Line or choice | Why |
| - | - |
| `golang:1.27-trixie` | The Go 1.27 line, on Debian 13, the image Ingot builds with too. A line tag, not a digest, so each build gets Go's and Debian's security fixes. |
| `go.mod` and `go.sum` copied before the source | `go mod download` runs in its own layer, which Docker reuses until `go.mod` or `go.sum` changes. |
| `-o /out/ . ./cmd/storefront` | Builds both programs, `catalog` and `storefront`, into one folder. The generated code in `catalogpb` is part of the source, so the image needs no `protoc`. |
| `alpine:3.24` | Static binaries need no more than Alpine: `/bin/sh` for the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) and the custom start command, and CA certificates. |
| `USER 1000:1000` | The servers run as a user with no privileges. The binaries stay owned by root. |
| `CMD ["catalog"]` | `catalog` starts by default. `storefront`'s custom start command, `exec /usr/local/bin/storefront`, replaces it. Both handle `SIGTERM` themselves, so they run as process 1 without `tini`. |
| `EXPOSE 8080` | Documents Sevalla's default `PORT`. The probe's port, `8081`, needs no `EXPOSE`. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.env*
*.log
/catalog
/storefront
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't build from your `Dockerfile`, and it adds the patterns in `.dockerignore` to its own, so you can keep both files in a repository that builds with Ingot. Then switch each application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

The build log shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, and the deployment's **Dockerfile** tab shows your file. The grpcurl calls in [Verify the deployment](#verify-the-deployment) and the `storefront` checks return the same results. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

With the API, send `{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}` to [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) for each application, then deploy each one.

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment of each application; otherwise, click **Deploy now** in each one. When you change `proto/catalog.proto`, run `protoc` again and commit the generated files with the code. Deploy `catalog` before a `storefront` that calls a new method. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add configuration on an application's **Environment variables** tab and read it with `os.Getenv`, as the servers read `PORT` and `CATALOG_ADDR`. Variables apply at the next deployment. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

Custom domains reach the web process with the same protocol as the application's `sevalla.app` domain, so gRPC clients can call `catalog.example.com:443`. Add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| grpcurl shows `rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: protocol error` | The web process's protocol is **HTTP**, so the proxy sends HTTP/1.1 to a gRPC server. | Switch the protocol to **gRPC** on the **Networking** tab. |
| `curl` or a browser gets `415`, with `grpc-message: invalid gRPC request content-type ""` | The domain serves gRPC only. | Call it with a gRPC client. |
| `storefront` returns `{"error":"catalog unavailable"}` with `503`, and its runtime logs show `catalog: rpc error: code = Unavailable desc = connection error: desc = "error reading server preface: connection reset by peer"` | `CATALOG_ADDR` names a port that isn't the web process's port or a private port, or nothing listens on it. | Use the web process's port, `8080`, or [expose the port](#use-a-private-port). Then deploy `storefront`. |
| `go build` fails with `no required module provides package github.com/acme/catalog/catalogpb` | The generated files aren't in the repository, for example because a `.gitignore` leaves them out. | Run `protoc`, then commit `catalogpb`. |

For more causes, see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds) and [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md).

## Deploy with the API

These calls make the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) and [Call the service from another application](#call-the-service-from-another-application), in an order that a script or an AI agent runs as it is. Each step names the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) operation, then the [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the `APP:CREATE`, `APP:READ` and `APP:UPDATE` permissions, in the `SEVALLA_API_KEY` environment variable. The CLI reads the key from `SEVALLA_API_TOKEN`.
* The repository on a Git provider that's connected to your Sevalla account. See [Git providers](https://docs.sevalla.com/git/overview.md). Creating an application checks it: when Sevalla can't read the repository, the call returns `400` with `Cannot access repository https://github.com/acme/catalog: Repository not found or you don't have access.`, followed by where to grant access.
* `curl`, `jq` and grpcurl.

Set the base URL, the authorization header and the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
H="Authorization: Bearer $SEVALLA_API_KEY"
REPO_URL=https://github.com/acme/catalog
BRANCH=main
```

**1. Choose the location and the size**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns the locations your company can use, with the region ID in `location`; both applications must be in the same one. [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) gives the ID of **H1**:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "$H" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$H" | jq -r '.[] | select(.name == "h1") | .id')
```

CLI: `sevalla resources clusters` and `sevalla resources process-types`. MCP: `get_platform_options`.

**2. Create the catalog application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates it with a web process and doesn't deploy it. [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns its domain a few seconds later:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO_URL" --arg branch "$BRANCH" \
    '{display_name: "catalog", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
for i in $(seq 1 30); do
  APP_HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "$H" | jq -r '.data[]? | select(.is_primary) | .name')
  [ -n "$APP_HOST" ] && break
  sleep 2
done
echo "$APP_HOST"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
catalog-x7k2m.sevalla.app
```

CLI: `sevalla apps create --name catalog --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO_URL --branch $BRANCH`. MCP: `create_application` with `deploy: false`.

**3. Set the protocol, the size and the readiness probe**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds the web process, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets `ingress_protocol` to `grpc`, the size to **H1** (the API creates it at **S1**) and the readiness probe on port `8081`. Send `timeoutSeconds`: a probe without it gets 120 seconds, where the dashboard sets 1. The response includes the web process's internal hostname, for `storefront`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "$H" | jq -r '.data[] | select(.type == "web") | .id')
CATALOG_HOST=$(curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" \
    '{ingress_protocol: "grpc", resource_type_id: $size,
      readiness_probe: {httpGet: {path: "/healthz", port: 8081, scheme: "HTTP"},
        initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -r .internal_hostname)
echo "$CATALOG_HOST"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
catalog-x7k2m-web.catalog-x7k2m.svc.cluster.local
```

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID` sets the size; the CLI can't set the protocol or probes. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**4. Deploy the catalog**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`), then poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final, for up to 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg branch "$BRANCH" '{branch: $branch}')" | jq -r .id)
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
```

On `failed`, read the build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`): `curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" -H "$H" | jq -r '.[].message'`. CLI: `sevalla apps deployments trigger $APP_ID --branch $BRANCH`, `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID` and `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `trigger_deployment`, `get_deployment` and `get_deployment_logs`.

**5. Call the catalog**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
grpcurl -d '{"id": 2}' $APP_HOST:443 acme.catalog.v1.CatalogService/GetProduct
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "id": 2,
  "name": "Pour-over kettle",
  "price": 39
}
```

**6. Create the storefront application**

The same call creates `storefront` from the same repository. [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets its web process's size and its start command, `entrypoint`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
STORE_ID=$(curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO_URL" --arg branch "$BRANCH" \
    '{display_name: "storefront", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
STORE_WEB_ID=$(curl -s "$API/applications/$STORE_ID/processes" -H "$H" | jq -r '.data[] | select(.type == "web") | .id')
curl -s -X PATCH "$API/applications/$STORE_ID/processes/$STORE_WEB_ID" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" '{resource_type_id: $size, entrypoint: "exec /usr/local/bin/storefront"}')" \
  | jq -c '{resource_type_name, entrypoint}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"resource_type_name":"h1","entrypoint":"exec /usr/local/bin/storefront"}
```

CLI: `sevalla apps create --name storefront ...` as for `catalog`, then `sevalla apps processes update $STORE_WEB_ID --app-id $STORE_ID --resource-type $H1_ID --command "exec /usr/local/bin/storefront"`. MCP: `create_application` with `deploy: false`, then `sevalla_api_update` with `updateProcessV3`.

**7. Connect storefront to the catalog**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`) with `target_type` `app`, then [Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds `CATALOG_ADDR`, for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/applications/$STORE_ID/internal-connections" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg id "$APP_ID" '{target_id: $id, target_type: "app"}')" | jq -r .id
curl -s -X POST "$API/applications/$STORE_ID/env-vars" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg value "$CATALOG_HOST:8080" '{key: "CATALOG_ADDR", value: $value, is_runtime: true, is_buildtime: false}')" \
  | jq -r .key
```

The first call prints the connection's ID, the second `CATALOG_ADDR`. CLI: `sevalla apps internal-connections create $STORE_ID --target-id $APP_ID --target-type app`, and `sevalla apps env-vars create $STORE_ID --key CATALOG_ADDR --value $CATALOG_HOST:8080`, which makes the variable available during the build too. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3`, and `set_environment_variables`.

**8. Deploy storefront**

Deploy and wait as for the catalog:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$STORE_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg branch "$BRANCH" '{branch: $branch}')" | jq -r .id)
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$STORE_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
```

CLI: `sevalla apps deployments trigger $STORE_ID --branch $BRANCH`. MCP: `trigger_deployment` and `get_deployment`.

**9. Check storefront**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) gives `storefront`'s domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
STORE_HOST=$(curl -s "$API/applications/$STORE_ID/domains" -H "$H" | jq -r '.data[]? | select(.is_primary) | .name')
curl -s https://$STORE_HOST/api/products/2
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"id":2,"name":"Pour-over kettle","price":39}
```

To deploy every push automatically, send `{"auto_deploy": true}` to [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) for each application. Then run the other checks in [Verify the deployment](#verify-the-deployment) and [Check health and use reflection](#check-health-and-use-reflection), with `$APP_HOST` as the domain.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.