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.
Every project has exactly one owner: the account paying for it. Everybody else is a member with a role.
| Role | What they can do |
|---|---|
| owner | Everything, 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. |
| editor | Ships, deletes individual services, manages who else is on the project. Cannot delete the project itself. |
| viewer | Reads 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.
What a project comes with
Three things exist inside every project without being asked for, because an application needs all three and none of them is worth making you create: a registry namespace for its images, a secrets store that resources publish into, and a memory.
The memory is where your coding agent leaves notes for the next session — why a decision went the way it did, the gotcha that cost an hour, the thing about this deploy that is not obvious from the repository. It asks for a briefing when it starts and writes down what it learned when it finishes, so the second session does not repeat the first one’s research. Notes are searchable by meaning as well as by word, and they link to each other. A fact that changes can be given a short key, such as deploy/current, and the next note saved under that key replaces the old one. A note can also be confirmed as still true or disputed as probably wrong, so that stale ones sink in the ranking instead of misleading the next session.
It is reached through mcp.gagarin.cloud and nowhere else: there is no gg command, because nothing here is for a human to type. Reading it needs viewer, writing needs editor, and it is billed by how many notes are in use — $0.10 per thousand a month, a few cents for most projects — with the number one project may keep in use capped. Reading is not charged. A note’s title and body are encrypted where they are stored; its tags, key, file paths and kind are not, and we hold the key — so nothing secret goes in a tag or a key, and no credential goes in a memory at all. That is the one place a resource is for.
Agents are asked to write .gagarin.json at the root of a repository — the project’s id and name, nothing else — so that the next session knows whose memory to ask for. It is a note, not a manifest: nothing on Gagarin reads it, every command still names its project out loud, and if it ever disagrees with the API then the API is right.
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.fr-par-1.apps.gagarin.cloud — service name, project id, and the cluster the project runs on (gg status shows it). 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.
The command prints the exact record to create; pass it on word for word rather than paraphrasing. A subdomain gets a CNAME to your cluster’s name, such as fr-par-1.apps.gagarin.cloud. An apex gets an A record pointing at the address the command names for your cluster’s 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 fr-par-1.apps.gagarin.cloud returns (use your own cluster’s name). 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 status | What is going on | Whose move |
|---|---|---|
| waiting for DNS | The name does not resolve to anything yet. | yours |
| DNS points elsewhere | It 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 certificate | It points here and we are getting the certificate. Usually under a minute. | ours |
| ok | Answering 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.
SERVICE READY PORT REACHES IMAGE ● web 1/1 8080 — api:v3 ○ └ https://shop.example.com waiting for DNS (you) ● └ https://web-8f2k1c0a.fr-par-1.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 at its timeout, an hour unless you set a tighter one: gg run radar/migrate migrate:v3 --timeout 5m. The clock starts when the run gets a machine, so waiting for one does not use any of it; pulling the image does. A run that never gets a machine is given up on once it has waited its timeout and half an hour more. It is billed from when its container starts, so neither the wait nor the pull is on the meter. At the deadline the run is sent SIGTERM, and killed thirty seconds later if it is still running. Something that needs longer than an hour is a service. And a name is one kind forever — a job cannot be deployed over as a service, or the reverse.
A job can also run on a schedule: gg run radar/import import:v2 --schedule "0 3 * * *" --tz Europe/Berlin returns at once and runs at every firing. The time zone is UTC unless you give --tz, and a schedule can fire as often as every minute. Runs never overlap: a firing that comes while the previous run is still going waits, and starts when that run ends. Each firing is a run like any other, so a failed one is reported once and not retried, and whoever opted in to alerts hears about it. gg run again without --schedule changes what the next firing runs and keeps the schedule; adding --schedule to a one-shot job makes it scheduled. A schedule cannot be removed. gg run means “run this”, and turning a schedule off with it would fire a run you did not ask for. Destroy the job and run it again.
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_useback, 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
--needsflag 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.
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. One vocabulary of 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. A resource that answers on two ports, qdrant, iggy or rabbitmq, gets a tunnel to both; for a rabbitmq the management UI is then on localhost.
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, valkey, iggy, rabbitmq, or s3 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 iggy is told its new root password first, and only then does the pod restart; nothing stored is lost, because a graceful stop flushes it, and the services that depend on it roll to pick up the new password. A rabbitmq is told first too, and the pod restarts because its environment changed; nothing on its volume is lost. An s3 bucket runs nothing of ours, but its dependents need the new key, so services that declare they need it are restarted — a few seconds away, nothing more, and calls on the old key fail until they come back up. 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
Seven of them. Five are things we stand up; one is a bucket we keep for you; the last 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:
| Type | What it is | Data |
|---|---|---|
| postgres | PostgreSQL 17. The ordinary relational database of a project, with the extensions you name — vector for embeddings, postgis for maps, and the rest of the list under “A postgres’s extensions” below. | A volume, sized when you create it. |
| qdrant | Qdrant — 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. |
| iggy | Apache Iggy — the Apache-licensed persistent message-streaming log. What an application publishes events to and consumes them from, in order, and reads them again later. It answers on two ports, and publishes both: TCP on 8090, the binary protocol every Iggy SDK speaks, and HTTP on 3000 for REST. QUIC and WebSocket are off. Its URL is the connection-string form the SDKs read, iggy://root:<password>@events:8090 for a resource called events, with the pieces alongside as <NAME>_HOST, <NAME>_PORT, <NAME>_HTTP_PORT, <NAME>_USER and <NAME>_PASSWORD. | A volume, same as postgres. Ten GB unless you pass --storage. |
| rabbitmq | RabbitMQ 4.4.0, with the management plugin — the MPL-licensed message broker. What an application sends work through: queues and exchanges over AMQP 0-9-1, where a consumer takes a message and acknowledges it (iggy, by contrast, is a log read at offsets). It answers on two ports, and publishes both: AMQP on 5672, and the management UI and HTTP API on 15672. Its URL is the AMQP form the client libraries read, amqp://app:<password>@jobs:5672/%2F for a resource called jobs, with the pieces alongside as <NAME>_HOST, <NAME>_PORT, <NAME>_HTTP_PORT, <NAME>_USER and <NAME>_PASSWORD. | A volume at /var/lib/rabbitmq. Ten GB unless you pass --storage. Durable queues and persistent messages survive a restart; not backed up. |
| valkey | Valkey — 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. |
| s3 | An S3-compatible bucket on Gagarin’s object storage, private until you say otherwise. We mint its key, and your services get the five settings every S3 client library asks for: <NAME>_ENDPOINT, <NAME>_REGION, <NAME>_BUCKET, <NAME>_ACCESS_KEY_ID and <NAME>_SECRET_ACCESS_KEY. Only that key can read or write the bucket; no other project’s key can, and nothing a service does can make an object public. It is reached over HTTPS from anywhere, not inside the cluster, so declaring it hands over the key and restricts nothing on the network — the key is the boundary, so keep it like one. gg resource public radar/files is the one way past it: it lets anyone list and read every object in that bucket, and nothing else; see “A public bucket” below. | Durable, not backed up. No volume and no size to choose: it holds what you put in it, and you pay for what it holds. We take no copy of it. Destroying it takes a human’s approval and deletes every object. |
| external | A 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, qdrant and iggy are backed up nightly and every backup is kept fourteen days — gg 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. It runs on the platform, not in your terminal: gg resource restore waits and shows it happening, and gg status says whether it is done or why it failed. One caveat for iggy: the backup is taken while the server keeps running, so only topics created with --durability persisted are guaranteed complete. A topic with the default durability may be missing its newest messages, the ones not yet flushed to disk — so create anything you would want back with --durability persisted. A rabbitmq is not backed up: it keeps its durable queues on its volume across restarts, but we take no backup of it, so declare your queues and exchanges from your application and a new broker can be rebuilt. Valkey keeps nothing across a restart by design. 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.
A postgres’s extensions
A postgres takes extensions: the set of postgres extensions the database should have, named exactly as CREATE EXTENSION names them. It is one more field on the call that creates or restates the resource — gg resource add shop/db postgres --ext vector --ext postgis, the extensions list on the MCP tool add_resource, or {"type":"postgres","extensions":["vector","postgis"]} on the API — and the resource says back which ones it has, in what the call returns and in its row of gg status. These are what it accepts:
| Extension | What it is |
|---|---|
| vector | pgvector: vector columns with HNSW and IVFFlat indexes, for embeddings. |
| postgis | PostGIS: geometry and geography types, spatial indexes and functions. No raster. |
| pg_cron | Scheduled SQL, run by the database itself. Its jobs run in database app. |
| pg_partman | Partition management, created in schema partman. |
| pgaudit | Audit logging to the resource’s log. Set pgaudit.log to turn it on. |
| pg_stat_statements | Statistics for every statement the server has run. |
| pg_trgm | Trigram similarity and indexes, for fuzzy text search. |
| pgcrypto | Hashing and encryption functions. |
| uuid-ossp | UUID generators beyond gen_random_uuid(). |
| citext | Case-insensitive text. |
| hstore | Key-value pairs in one column. |
| ltree | Hierarchical labels and tree queries. |
| unaccent | A text search dictionary that removes accents. |
| fuzzystrmatch | Levenshtein, Soundex and Metaphone. |
| btree_gin | GIN operator classes for ordinary types. |
| btree_gist | GiST operator classes for ordinary types, for exclusion constraints. |
| cube | Multi-dimensional cubes. |
| earthdistance | Great-circle distances. Adds cube, which it is built on. |
| intarray | Functions and indexes for integer arrays. |
| tablefunc | Crosstab and other table functions. |
| amcheck | Verifies the integrity of indexes and tables. |
| pg_buffercache | What is in shared buffers. |
The first five are compiled in; the rest ship with postgres itself. What is not on the list is not offered: not timescaledb, whose licence forbids exactly a hosted service; not raster; not dblink, postgres_fdw or file_fdw; and no untrusted languages. And pgvector is called vector, because that is the name postgres knows it by — ask for pgvector and the refusal tells you so.
The rules are the ones --size already taught you. The list is the whole set, not a change to it: name every extension you want kept. Leave the field out and the extensions stay exactly as they are, so a restatement from an old file cannot drop one by forgetting it; [] means none. An extension another one is built on comes with it — ask for earthdistance and you have cube too. A type other than postgres refuses the field, with no_extensions.
Adding one is immediate, on the running server, with no restart. On a new postgres they exist before the database is first ready, so the first migration can use them. Dropping one asks nobody, unlike every other removal, because a drop refuses rather than loses anything — so a drop that succeeds cost you nothing. It never cascades: a column, an index, a view or a SQL-standard function that uses the extension — a vector column, say — stops it. And it is refused while the extension’s own tables hold your data: pg_cron’s jobs in cron.job and their run history, pg_partman’s partition config in partman.part_config, a spatial reference system you added to PostGIS’s spatial_ref_sys. Nothing depends on those tables, so without that guard a plain DROP would delete every scheduled job. Either way you get extensions_failed with postgres’s own reason in it — column e of table items depends on type vector, or extension pg_cron holds 1 row(s) of your data in cron.job. The change is one transaction, so a refused one changes nothing: not the extensions, and not the rest of the restatement either. To really drop it, remove what is in the way yourself, then make the same call again.
One thing postgres cannot see: a plpgsql function whose body uses the extension. The drop goes through and the function fails when it is next called — nothing is lost, and adding the extension back mends it.
Every postgres runs the same image, whatever it asked for: rg.fr-par.scw.cloud/gagarin-resources/postgres:17.11-1, PostgreSQL 17.11 on alpine with all of the above compiled in, built from core/images/postgres and public. pg_stat_statements, pg_cron and pgaudit are preloaded in every one of them, which is why adding any extension needs no restart. It is also why backups do not care: a dump carries its extensions, every postgres has every one, so any postgres backup restores into any postgres, and the restored resource’s extensions are read back from the restored database itself.
app is still a superuser in its own pod and can run CREATE EXTENSION by hand. What it creates that way is its own business, and is not recorded in extensions.
Vectors on a postgres, or a qdrant? vector on the postgres you already have keeps the relational tables and the embeddings in one database, joined in one query and written in one transaction. That is the default for an application that already needs postgres and is adding retrieval. Take a qdrant when the vectors are the product.
A public bucket
An s3 bucket is private until you ask. gg resource public radar/files adds one anonymous statement to its policy: anyone, with no key, can list the bucket and read any object in it at https://<bucket>.s3.<region>.scw.cloud, and can do nothing else. Writing, deleting and changing the bucket’s access still need the key, and the key still cannot change the policy itself. gg resource private radar/files takes the statement away again.
It is a command of its own, not a setting on the resource, so restating gg resource add or rotating the key never undoes it. Services that declare they need the bucket get <NAME>_PUBLIC_URL while it is public and lose it when it goes private, and are restarted each time. gg status shows the address under the resource. Only an s3 resource can be made public; a service gets an address with gg domain add.
What you are agreeing to, said plainly. It is the whole bucket, not chosen objects, and a listing shows every object’s name and size, so do not put a name in it you would not publish. Making it public also sets a CORS rule, so a browser on any site can fetch from it, load its fonts and modules, and show its images; the rule allows only GET and HEAD, and writes still need the key. Making it private removes the rule. Traffic out of a public bucket is not billed to you, but we do not yet alert on one bucket using a great deal of it. A suspended project’s public bucket keeps serving reads. And destroying a public bucket makes it private first, so it is not readable while its objects are being deleted.
When the type you want is missing
Five 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.