gagarinDocumentation

The model

Four nouns cover everything: project, service, resource, dependency. If a thing cannot be said in those, it does not ship.

Project

A project owns everything else — one per application. It has two names, and that is not redundancy. The name is yours to pick and it only has to be unique inside your account, so api can belong to you and to everybody else at the same time. The id is ours: eight lowercase letters and digits, and it is the id that turns up in addresses, in the namespace and in the image path.

That split earns its keep. Globally unique names mean whoever grabbed api first took it from everyone, and a hyphen in a name would make an address ambiguous to read. Ids never contain a hyphen, so web-8f2k1c0a comes apart into exactly one service and one project.

web-8f2k1c0a.apps.gagarin.cloudservice nameproject id8 charactersshared by every service
fig. 2 · An id never contains a hyphen, so web-8f2k1c0a comes apart into exactly one service and one project. Reading an address never involves a guess.

Every project has exactly one owner: the account paying for it. Everybody else is a member with a role.

RoleWhat they can do
ownerEverything, including deleting the project and running gg eject. Pays for it. Not a role you hand out or take back — it is a fact about the project rather than a seat at it, and it moves only by being offered and accepted.
editorShips, deletes individual services, manages who else is on the project. Cannot delete the project itself.
viewerReads state, logs and the member list. That is the lot.

Deleting a project throws away data and addresses the owner is paying for, so it stays with them. An editor gets owner_only back, and that is not the kind of error an approval fixes — there is nobody who could grant it.

A project can still change hands. gg transfer radar them@example.com offers it to somebody it is already shared with, and nothing happens until they accept: they get an email, and the project moves when they press the button. Sharing is one-sided because access is a gift; ownership is the bill, and nobody should be able to make somebody else start paying for something by typing their address. When it lands, the project keeps its id — so its services, addresses and data are untouched and nothing restarts — usage is billed to each account for the part of the month it had, and the previous owner stays on as an editor.

Service

A service is a container that keeps running. It has an image, a port, and whatever addresses it answers on. Services start private: no address from outside, and its neighbours reach it by name — http://worker:8080 — but only the ones that declared they call it. There is no service discovery to set up, because there is nothing to set up.

gg domain add radar/web is what makes it public, and it gets an address like web-8f2k1c0a.apps.gagarin.cloud — service name, project id. Going public is something you say out loud, because the two mistakes do not cost the same: a service you forgot to open fails the first time somebody tries it, and a service you forgot to close never fails at all.

The same command adds a name of your own; only the argument changes. That is not a coincidence. An address we handed you and a domain you bought are the same fact: the service answers here. Two verbs for one fact would only make you collapse them back into one every time you read the output. A deploy touches neither.

Images come from the platform registry, out of your project’s own namespace: registry.gagarin.cloud/<project id>/<repository>. You cannot point at a neighbour’s image or at something on Docker Hub — the API says no. And pushing an image does not ship it: two calls, always, because building should not start a rollout behind your back.

Environment variables are replaced wholesale rather than merged, so a deploy without a variable deletes it. That is annoying exactly once and predictable forever after — and it is the only thing a deploy replaces, because the environment is part of what that deploy was: a rollback puts it back and history shows it. Everything else you could lose by forgetting to repeat it — dependencies, domains, volumes — a deploy will not touch.

A domain of your own

A public service answers on the address we gave it from the first deploy. To have it answer on a name you own as well: gg domain add radar/web shop.example.com. The original address keeps working — the service answers on both — so old links survive, and you have something to test against while DNS catches up.

This takes two steps and only the first is ours. The command makes the service answer for that name. Making the name lead here needs a record at your registrar, and we cannot create it for you. Neither step does anything alone, and the gap between them is not a fault — it is the ordinary first state.

There is no HTTPS until that record exists either. Let’s Encrypt proves you own the name by reaching it over the internet, so the certificate cannot come before the name points here.

WE DO THISgg domain addthe declarationan Ingress ruleit answers for the nameYOU DO THISa DNS recordat your registrarthe name leads hereresolves to our addressLet’s Encryptrequests that nameHTTPS
fig. 3 · You prove you own the name over the name itself, which is why the certificate cannot come before the record. That order is not ours to set, and “no HTTPS yet” up to that point is not a fault. An address we hand out has no second half — the zone and the certificate are both ours — so gg domain add radar/web with no name after it finishes entirely on our side.

The command prints the exact record to create; pass it on word for word rather than paraphrasing. A subdomain gets a CNAME to apps.gagarin.cloud. An apex gets an A record pointing at our load balancer, because DNS will not resolve a CNAME at the top of a zone. That is a rule of DNS, not a choice of ours, and there is nothing to “fix” by turning the A record into a CNAME.

One case we get wrong, and you should hear it from us rather than from your registrar: names with a compound suffix like example.co.uk have three parts, so we take them for a subdomain and print a CNAME. Your registrar will refuse it. Create an A record instead, pointing at whatever dig +short apps.gagarin.cloud returns. Getting those suffixes right needs the public suffix list — another dependency to keep current, for a mistake that costs one line.

Past that point a domain is a handshake between three parties: us, a registrar we do not control, and a certificate authority. So gg status does not answer “ready or not”. It answers whose move it is:

Line in gg statusWhat is going onWhose move
waiting for DNSThe name does not resolve to anything yet.yours
DNS points elsewhereIt resolves, but not to us, and the status says where it goes instead. This is the only one of the four where something is actually wrong rather than merely unfinished.yours
issuing certificateIt points here and we are getting the certificate. Usually under a minute.ours
okAnswering over HTTPS.nobody’s

Addresses get their own lines under their service rather than a column of their own. Three of the four states are a sentence rather than a badge, and a column as wide as a hostname would throw the whole table off for the sake of one row. Your own name comes first, since it is the one that might be waiting on you. A finished address has no status at all — the filled dot on the left already says everything is fine.

gg status radar
     SERVICE  READY  PORT  REACHES  IMAGE
  ●  web      1/1    8080  —        api:v3
  ○  └ https://shop.example.com                 waiting for DNS (you)
  ●  └ https://web-8f2k1c0a.apps.gagarin.cloud

One trap that has people redoing a perfectly good record. DNS caches “no such name” too, so a resolver that looked before you created it keeps handing back that answer until the zone’s negative TTL runs out — usually about a quarter of an hour. Which is why dig on your machine already shows an address while gagarin still insists it does not resolve. The right move here is to wait.

Domain state is deliberately kept out of the general converged-or-not signal. The reconciler brings the cluster to the state you asked for. It will not bring somebody else’s DNS zone anywhere, in a minute or ever, and showing that with the same marker as a pod still rolling out would promise a convergence that is never coming.

The rest, briefly. A deploy never touches an address, in either direction; only gg domain add and gg domain rm do, for the same reason dependencies have a command of their own. A name belongs to exactly one service across the whole of gagarin, and if somebody else has it you get refused without being told who — that a name is taken is a fact about the name, whose it is, is a fact about their account. A private service picks up its own name and an address from us in one command, so it is reachable somewhere from the first second while DNS settles. Resources get no address at all: you reach them from inside the project, by name. And you cannot claim a name under our apps.gagarin.cloud — every service there already has one.

Taking an address away weighs the same as destroying a service, and it needs the same emailed approval from a human. What breaks is not what you can see from here — it is what the people who already have that address can see. You cannot drop the address we gave you while a name of your own is still declared: your record would keep pointing at us after we stopped answering for it, so your name comes off first.

When you do drop a domain, remove the record at your registrar too. It will go on pointing here, and we no longer answer for the name, so your visitors get somebody else’s error page instead of yours.

State on disk

--volume /var/lib/postgresql/data keeps a directory alive across restarts, and --volume-size sets the ceiling (10 GB by default). A volume’s path is set once, on the first deploy — a later one cannot move it, and gets volume_immutable back. That is not a technical limitation. It is a refusal to pretend moving data is a side effect of shipping.

The size can be raised: restate a resource with a bigger --storage and the volume grows in place. It can never be lowered, and that one is a technical limitation — a claim only expands, so a smaller number is impossible rather than disallowed.

How big it runs

--size s, --size m or --size l — 0.5 vCPU / 1 GB, 1 vCPU / 2 GB, or 2 vCPU / 4 GB. The same word on a service and on a resource, because a postgres at m is the same decision as a web service at m. s is shared and bursts; m and l reserve exactly what they promise. Prices are on Pricing.

Unlike a volume, a size can be changed later — it is cheap and reversible where moving data is neither. Leave --size off and the service keeps the size it already has; it does not fall back to the smallest. If a container is killed for running out of memory, gg status says so and names the size to try next, which is the one failure here you can fix without reading a log.

History and rollback

Every deploy of a service is written down. gg history radar/web lists them newest first, gg rollback radar/web puts the previous one back, and --to picks a particular one. A rollback goes through the same door a deploy does, with the earlier values — it is not a separate restore mechanism, it is the same call. You cannot roll back across a change of volume, for the same reason as above.

A rollback restores the image and the environment — what that deploy was. It does not restore dependencies, domains or volumes: those are not parts of a rollout, they are what outlives one. Putting yesterday’s code back is not a statement about who is allowed to reach this service today.

A job: a service that ends

Some images are not servers. A migration, a backfill, a nightly import — they run, they finish, and the thing you want to know is how they finished. Deployed as a service, one of those is a disaster in slow motion: it exits 0, the platform restarts it because that is what a service is, and it lands in a crash loop with the meter running.

So a job is its own kind. gg run radar/migrate migrate:v3 submits one and waits — the only command here that does — printing what the run wrote and exiting with the script’s own exit code, which is the thing a pipeline branches on. It has an image, an environment, a size and whatever it may reach; it has no port, no address and no volume, because it listens on nothing and keeps nothing between runs. Durable data belongs in a resource it reaches.

Every gg run is one run, recorded as a revision exactly like a deploy — so gg history lists the runs and gg rollback runs an earlier image again. A run that fails is not retried: it is reported once, with its code, because nothing here can know whether running a half-applied migration twice is safe. A run is stopped after an hour; something that needs longer is a service. And a name is one kind forever — a job cannot be deployed over as a service, or the reverse.

The shape for a deploy that needs a migration first is two commands, in that order, and the first one’s exit code is the gate:

gg run    radar/migrate migrate:v3 --deps db
gg deploy radar/api:8080 api:v3

Dependency

An edge between two services in one project. gg deps add radar/api db means “api calls db”. You declare an edge rather than drawing one, and it does three things at once.

  • It opens the path. A connection nobody declared does not exist — a private service is reachable by exactly the services that named it and by nothing else. Which is why the graph stays honest without anybody policing it: if it were wrong, the application would not work.
  • It guards the exit. You cannot tear down something another service still leans on. You get service_in_use back, and the refusal names what is leaning — before anything breaks, not after.
  • It outlives a deploy. Shipping does not touch the graph, in either direction. This used to be a --needs flag with the list replaced on every deploy, which meant a rollout that forgot to repeat it quietly took access away. A command instead, because you can forget a flag but not a command you never ran.
PROJECT RADAR · ID 8F2K1C0Awebpublicworkerprivatedbresource · postgresgg deps addgg deps addnot declared — no connectionservice — an image you builtresource — run by the platform
fig. 4 · An edge is not a picture beside the system, it is the system. A path nobody declared does not connect, so the graph you can read cannot come apart from the one that is running.

The price is named honestly: an undeclared edge breaks the call rather than merely going unlogged. And it breaks quietly — the packet is dropped, not refused, so the call does not fail with an error, it hangs until your client gives up. What you get for that is never having to ask “so who actually talks to this database?” again. You do not piece that answer together out of security groups. You read it.

Which is also why gg deps add goes through on your agent’s credential and gg deps rm does not. Opening a path breaks nothing. Closing one breaks something and tells nobody: the calls stop being delivered, the caller waits on a socket that will never answer, and there is no error anywhere to search for. So a withdrawal weighs what destroying a service weighs, and asks for the same emailed click, with the edge named in the sentence you are approving.

Going out is unrestricted, with one exception worth knowing before you build on it: the mail ports — 25, 465 and 587 — are blocked. A service that can open those can relay spam if it is ever compromised, and a hosting account that relays spam is a hosting account that gets suspended, taking every other project on the platform down with it. If your application sends email, send it through a provider’s HTTPS API, which is what gagarin does itself. The full list of what is not allowed is in the terms.

Resource

Something a project has, rather than something it runs. A service is an image you built; a resource is one we stand up. You give it a name and a size, and none of the rest is your problem.

gg resource add radar/db postgres is the whole command. No image, no version, no Dockerfile.

You declare a dependency on a resource exactly as you would on a service: gg deps add radar/api db. That opens the network path, and it hands api the database’s connection variables. One call, both halves — because they were never useful apart, and the half people forgot was the one that fails without saying so.

The variables are named after the resource. A postgres called db gives you DB_URL, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD and DB_DATABASE; one called orders-db gives you ORDERS_DB_URL, and so on. Six suffixes, the same for every type, with the ones a type does not have left out rather than left empty. So a project can hold two databases and a service can depend on both, and nothing collides.

They are not copied into your environment — they are resolved from the graph every time the service is applied. That is what makes the guarantee worth having: a redeploy cannot forget them, because injecting is not a step anybody performs. Withdraw the dependency and they are gone again, on the same call. If you set a variable of your own with one of these names, the resource wins; it is the only thing in gagarin that outranks what your deploy said, and it is because only the resource knows the real value.

gg resource secrets radar/db --names lists what a resource publishes without fetching a single value — the question you usually have, and the one to ask when the answer is going anywhere you would not paste a password.

You can still read the values themselves — gg resource secrets radar/db — and for the times you want to psql into it from your laptop, gg connect radar/db opens a tunnel and prints the same variables rewritten to point at 127.0.0.1. It lasts exactly as long as the command does, and nothing is exposed: the database stays off the internet, and the tunnel exists only on the machine that opened it.

Rotating them

gg resource rotate radar/db. One command, every type, and everything holding the old credential restarts with the new one. You do not work out which services those are — the graph already knows, and the command tells you which ones it rolled.

This is the part that only works because the credentials were never copied. A password that had been pasted into three deploys would need three more to rotate, and the one you forgot would be a service authenticating with a revoked key until somebody noticed. Resolved from the graph instead, there is one place to change and no list to keep.

Who supplies the new value is the only difference between the types. For a postgres, qdrant or valkey we mint it — a password you chose is one the running server has never heard of. For an external the values are yours, so you pass them: gg resource rotate radar/openai --env-file .env.new.

An external usually holds more than one value, and most rotations replace exactly one of them — the key leaked, the other settings did not. gg resource rotate radar/openai --set API_KEY=sk-new changes that one and leaves the rest precisely where they were, and --unset takes one away. The --env-file form above means something stronger: the bundle is now exactly this, and anything not in it stops being published. That is what you want after a provider migration where every value is new, and it is a way to lose the others by accident when it is not — so the command tells you what it dropped, at the moment it drops it, rather than leaving you to find out from a service that can no longer authenticate.

The costs differ and we would rather say so than have you find out. A postgres is told immediately, with no restart and no downtime. A qdrant and a valkey both read their credential when they start, so the pod is replaced — for a qdrant that costs the few seconds it is away and nothing else, because its data is on a volume, and for a valkey it empties the cache, the same as any other restart of one. An external runs nothing of ours, so nothing of ours restarts. And if a rotation fails, nothing changed: the old credential is still in use and the command is safe to run again.

Undoing one

A rollback restores the image and the environment you deployed with, and not the variables a service inherits from the resources it needs. That is deliberate: those are resolved from the graph as it stands now, so a rollback can never put a service back onto a password that has since been rotated.

Which means you undo a change to an external where you made it — gg rollback radar/config --to 4 — and every service declaring it is restarted with the restored values. Rolling back one dependent would be the wrong shape for something shared: it would quietly reconfigure the others too. Managed types refuse this, because we mint their credentials and there is no earlier value of yours to return to.

The types

Four of them. Three are things we stand up; the fourth is a third-party API we do not run at all. The type decides what we stand up, and what we owe you a straight answer about:

TypeWhat it isData
postgresPostgreSQL 17. The ordinary relational database of a project.A volume, sized when you create it.
qdrantQdrant — the Apache-licensed vector database. What an application does retrieval against: embeddings in, nearest neighbours out. It is the one type whose credential is not in its URL, because Qdrant authenticates on a header rather than in the address — so you get <NAME>_URL and <NAME>_API_KEY separately, which is exactly the pair every client library asks for. It answers on two ports, HTTP and gRPC, and publishes both: the Go and Rust clients speak only the second one.A volume, same as postgres.
valkeyValkey — the BSD-licensed Redis fork, protocol-compatible, so any client library you already use works unchanged. The URL arrives as <NAME>_URL like every other type, with redis:// as its scheme — which is the part a library reads.In memory. No volume: a pod restart loses the lot. This is a cache and a session store, not a database.
externalA third-party API — an OpenAI account, a Stripe key, a bucket somewhere else. We run nothing for it. You give it the values, it publishes them under its own name, and the services that declare they need it hold them. It is how a key stops being a copy pasted into three deploys and becomes one thing with one place to change it. Note what it is not: declaring one grants the credentials and nothing on the network — anything in your project could already reach the internet, and still can.None. No container, no port, no size, no backups, nothing to be ready.

Said plainly: a resource is one pod, and where there is a volume, one volume. Postgres is dumped nightly and every dump is kept fourteen daysgg resource backups lists them, gg resource backup takes an extra one before something risky, and a restore builds a new resource from a dump rather than overwriting a live one, so it needs nobody’s approval. Valkey keeps nothing across a restart by design, and qdrant has no backups yet. Beyond that, unmanaged still means unmanaged: no point-in-time recovery and no failover. The order is on purpose — we want to know the resource model is pleasant to live with, in how you declare them and connect them and read them, before we take on running them fully. Managed resources are coming, and the API was built from day one as though they already were, so the implementation underneath can be swapped without anything moving on your side.

When the type you want is missing

Four types will obviously not cover everything. DuckDB, Cassandra, ClickHouse, a document store — the list of what somebody needs is always longer than the list we are willing to maintain. That is not a dead end: take a published image and run it as an ordinary service with a volume.

gg registry copy radar/clickhouse clickhouse/clickhouse-server puts the image in your project’s registry, and gg deploy radar/analytics:8123 clickhouse --volume /var/lib/clickhouse stands it up.

Everything works the same way: a private address by name, gg deps add radar/api vectors for whoever calls it, a volume that survives a restart, and a refusal to delete it while somebody needs it. The only difference is who makes the decisions. For a resource we pick the image, the version and the port, and we answer for them. Here you pick, and the upgrades, the tuning and the consequences are yours.

So the rule is simple. If the type exists, take the resource — fewer decisions, fewer ways to get it wrong. If it does not, a service with a volume is not a workaround, it is the ordinary way, and it is not going anywhere.

State

There is the state you asked for and the state the cluster is actually in. gg status shows you both and tells you whether they agree. A store that only reported its own intentions would reproduce the exact problem this was all started over.

platform databaseDESIREDclusterACTUALreconcilerreadsconvergeschecksgg statusshows both sides and answers whether they agree
fig. 5 · A store that only reported its own intentions would reproduce the exact problem this was all started over. Which is why gg status reads both sides and not one.