gagarinWriting

A database, two services and an address, in five commands

A walk through an ordinary deploy — the Dockerfile, the five commands, what the engine says back, and the one command it refuses.

Everything below is one project, built from an ordinary repository with no gagarin-specific anything in it. No manifest, no config file, no YAML: there is nowhere on this platform to put one, which is the point and is also why this post is short.

The app is a Go API, a web front end that calls it, and a Postgres under both. Call the project feed.

1. The Dockerfile is the only file we need

If your repository already builds an image, you are done before you start. Ours is the usual two-stage build:

FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/feed ./cmd/feed

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/feed /feed
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/feed"]

Two things about it matter here and nothing else does. It listens on one TCP port, and it reads its configuration from the environment. That is the entire contract — anything that satisfies it runs here.

2. The project, and the database under it

gg init feed
gg resource add feed/pg postgres

gg init creates the project everything else is named for. gg resource add provisions the Postgres and, more to the point, keeps the credentials. Nobody types a password into anything after this, and no connection string goes near a repository.

3. Ship it

gg ship builds the current directory, pushes the image and deploys it — three commands' worth of work, and they are still there separately for the day you want them apart.

gg ship feed/api:8080 --deps pg
gg ship feed/web:3000 --deps api

The number after the colon is the port the container listens on, and --deps is the whole of the configuration: it says what this service may reach, and the platform hands it the connection variables of any resource in that list. The database password is never typed and never committed.

Note what is not in those commands. No replica count, no rollout strategy, no health-check path, no ingress class, no namespace, no service account, no NetworkPolicy. All of those exist underneath — this is Kubernetes down there — and none of them is a decision worth making at this point in the afternoon.

4. Nothing reaches anything it has not declared

This is the part people are usually surprised by, and it is the part worth reading twice.

A private service is default-denied. Until the edge is declared, the call is dropped — and a dropped packet hangs, it does not fail fast.

--deps declared them on the way past. The graph also moves on its own, without a deploy:

gg deps ls  feed/api
gg deps add feed/api pg
gg deps rm  feed/api pg

Adding an edge is yours. Taking one away asks a human to approve it, because it is the one change that breaks something and reports nothing: the service keeps running, the status stays green, and the call simply stops arriving.

The graph is not a picture drawn from variable references after the fact. The graph is the firewall.

A migration is not a service

A job runs to completion and listens on nothing. Same image, run once, reaching the database the same way:

gg run feed/migrate feed:2026-09-01 --deps pg

gg run waits, prints what the job wrote, and exits with the job's own exit code — so CI can branch on it as though it had run the migration itself.

5. An address

gg domain add feed/web feed.example.com

Certificate and ingress come with it. Leave the domain off and the service gets a generated one on apps.gagarin.cloud instead.

A resource cannot have an address. Ask anyway, and the engine says so rather than doing something clever:

{
  "error": {
    "code": "not_a_service",
    "message": "pg is a postgres resource, not a service",
    "fix_hint": "a resource is reached from inside the project by name and is never public"
  }
}

Every error carries a stable code and a fix_hint. Branch on the code, never on the prose — the error contract is the whole list.

What it looks like once it is up

$ gg status feed
project feed  (id eqteoeay)

    SERVICE  KIND      SIZE  READY  PORT  REACHES  IMAGE               VOLUME
  ● api      service   s     1/1    4000  pg       api:1788569515      —
  ● pg       postgres  s     1/1    5432  —        postgres:17-alpine  10GB /var/lib/postgresql/data
  ● web      service   s     1/1    8080  api      web:1788569576      —
    └ https://feed.gagarin.cloud
    └ https://web-eqteoeay.apps.gagarin.cloud

  ● running
  $0.789 today so far

One screen: what runs, what it reaches, what it costs today. The REACHES column is the firewall, printed.

The gagarin console: a project with two running services, five external resources, and the graph of what reaches what

A different project in the console — the same reading, drawn. There is nothing to press that is not also a command: one source of truth, and a second way to change it is exactly what we are not building.

And the command it will not run

The database has a service depending on it now. Ask for it to be destroyed and the answer is no — not a confirmation prompt, not a --force, just no:

gg destroy feed/pg → 409 service_in_use, naming what holds it, and the hint is the command that would let go first. Same graph as step 4, read from the other end.

The whole afternoon:

stepcommandwhat it is
1—your Dockerfile, unchanged
2gg init + gg resource adda project, and a Postgres
3gg ship ×2two services, and the edges between them
4gg depsthe graph, when it moves without a deploy
5gg domain addthe address

Five commands, and the only decision in them was which service gets a port.

If you want to follow along with your own repository, gg login prints a link and a code, and there is $5 on a new account to spend. If you want to know what happens the day you want out, we wrote that down too: leaving.