greyship / how it works
How Greyship works
Greyship is serverless for Go. This is exactly what happens when you deploy, how your app sleeps and wakes, and how your code and secrets are kept apart from everyone else's. Step through the boards; nothing is hand-waved.
8 chapters · 19 min read
01 · 3 min
How Greyship works
Greyship is serverless for Go: a control plane that builds and places your apps, and a data plane that runs them in sandboxes and wakes them on the next request.
Greyship runs Go backends without servers to manage. You push a normal Go program (any router, any layout) and get a URL. When nobody is using it, it sleeps and costs nothing; the next request wakes it. This page is the map. Every other article zooms into one part of it.
Two planes
The system is split in two. The control plane decides what should run: accounts, projects, deployments, configuration, scheduling and billing. The data plane actually runs it: edge routers that receive traffic, and servers that host your app inside sandboxes.
Two paths cross these planes. The deploy path turns your source into binaries and places them on servers. The request path takes a visitor's request to a running copy of your app, waking it if needed.
The parts, one line each
| Part | What it does |
|---|---|
| API | The public /v1 API. The CLI, the dashboard and GitHub webhooks all talk to it, and it is the only part that writes to the database. |
| Builder | Fetches your source, works out your services, downloads modules through a cache and compiles each service with go build, inside a sandbox. |
| Scheduler | Keeps a picture of every server's free memory and decides where each instance runs. |
| Node agent | Runs on every server. Creates, pauses, resumes and stops sandboxes, decrypts your env vars at start and collects logs and metrics. |
| Guest init | The first process inside every sandbox. Starts your binary, forwards signals and captures stdout and stderr. |
| Edge router | Terminates HTTPS, maps hostnames to services, holds requests while an app wakes, and puts apps to sleep after they go quiet. |
Why only Go
Every Greyship app is a single static Go binary. That is what makes the rest possible: no container image to pull, no runtime to boot, a tiny root filesystem, and start-up in milliseconds. It also lets Greyship tune the Go runtime for you: GOMAXPROCS and GOMEMLIMIT are set to your instance's real CPU and memory limits, so Go never throttles itself or runs past its memory.
Where to go next
- What happens when you run grey deploy: the deploy path, step by step.
- How Greyship reads your repo: how services are found with no config.
- Sleeping and waking: the request path and why idle costs $0.
- How your secrets are protected: encryption from your laptop to your app.
- Isolation and networking: why tenants can't reach each other.
02 · 3 min
What happens when you run grey deploy
Step by step: how grey deploy links your project, diffs env vars, uploads source, builds each Go service in a sandbox, runs migrations and switches traffic with zero downtime.
grey deploy is the one command you need. Run it in your Go project and a few seconds later you have a live HTTPS URL. Here is everything it does on the way, in order.
$ grey deploy ✓ Created project orders-api (from go.mod) .env.prod vs production: + 2 keys, = 9 match ✓ Uploaded 412 files (1.8 MB) api web starts a server in cmd/api/main.go:22 worker worker no server start found from main() ✓ Built 2 services ✓ Release step: cmd/migrate exited 0 ✓ Live https://api-orders-api.greyship.app
1. Knowing which project
The CLI looks for .greyship/project.json in the current folder and its parents. It holds the team, the project name and the project's id. It contains no secrets and is meant to be committed, so teammates and CI deploy to the same project.
You don't need to run grey init first. If there is no link, grey deploy takes the name from the last part of your go.mod module path (github.com/acme/orders-api becomes orders-api), creates the project and writes the link. The only time it stops to ask is when your team already has a project with that name: it never deploys over someone else's project without a yes.
2. Comparing env vars, without sending them
Your stored production variables are the source of truth. The CLI fetches their names and value hashes (never the values), hashes .env.prod on your machine, and shows what would change:
.env.prod vs production: + NEW_FLAG only in file → will be added ~ STRIPE_KEY different → will be updated = 12 keys match · SENTRY_DSN only stored → kept Continue? [y/N]
Only added and changed values are sent, over TLS, to the encrypted env store. They never travel with your source code and are never visible to the build. Deploy never rewrites your local file. More in How your secrets are protected.
3. Uploading your source
The CLI packs the folder, honouring .gitignore and .greyshipignore, and asks the API for a one-time upload URL. The tarball goes straight to object storage, not through the API.
4. Finding services and building
A builder picks up the job and starts a fresh sandbox for it. The sandbox can reach only the Go module cache, nothing else on the internet or the platform. It works out your services (see How Greyship reads your repo), then for each one runs:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" ./cmd/api
The Go version comes from your go.mod (toolchain, then go). Modules are fetched through a shared cache, so repeated deploys don't download the internet again. If any build fails, the deployment is marked failed and your current version keeps serving.
5. The release step
Database migrations need to run once, before new code takes traffic. If your repo has cmd/migrate (or you set [release] in greyship.toml), it is built with everything else and run exactly once per deploy, in the same sandbox and network rules as your services and with your production env. Only one release runs per project at a time. If it exits non-zero, the deploy stops and the old version keeps serving.
6. Health check and traffic switch
One instance of each web service starts and must accept connections on $PORT within 30 seconds (or answer 200 on your health_path). Once every service is healthy, the routers switch to the new version at once. The old instances finish their in-flight requests, get SIGTERM, and stop. Apps that scale to zero then go back to sleep until the first real request.
03 · 2 min
How Greyship reads your repo
Greyship finds every service in a Go repo with no config: it lists main packages, names them by folder and reads your code to tell web services from workers.
Most platforms make you describe your app in a config file. Greyship reads your repo instead. A normal Go backend (one API, or an API plus workers plus crons in one module) deploys with no greyship.toml at all.
Finding services
Every folder with a package main is a candidate. Files are only counted if they'd be part of a Linux build: _windows.go files and files behind //go:build ignore or //go:build tools drop out, exactly as they would for go build.
| Where the main lives | Becomes |
|---|---|
| main.go or cmd/main.go | one service named after the project |
| cmd/<name>/ | a service named <name> |
| anywhere else | a service named after its folder; if two clash, the nearest meaningful parent is added (orders-server, payments-server) |
| cmd/migrate/ | the release step, run once per deploy |
| vendor/, testdata/, examples/, _x/, .x/ | skipped |
only run by //go:generate go run | skipped, it's a tool |
Web or worker: reading the code
Greyship doesn't guess from folder names. It parses your main() and follows the calls into your own packages. If anything reachable starts a server, the service is web; otherwise it is a worker. It recognises the standard library (http.ListenAndServe, http.Server.ListenAndServe, net.Listen) and the popular frameworks: gin, echo, fiber, chi, gorilla/mux, gRPC, Connect, fasthttp and Hertz.
Being careful matters as much as being clever. A worker that calls http.Get is still a worker: using HTTP as a client doesn't make it a server. A shared package that contains a server function your worker never calls doesn't make it web either.
api web starts a server in internal/server/server.go:41 (http.ListenAndServe) worker worker no server start found from main() cleanup cron type = "cron" in greyship.toml
When you want to override
greyship.toml is optional and only for the few things that can't be inferred. Cron schedules are the main one, since nothing in code says "run at 3am". A declared name that matches a detected service overrides it; path renames a service or includes a folder that was skipped; exclude leaves one out.
[services.cleanup] type = "cron" schedule = "0 3 * * *" [services.public] path = "./cmd/api" # rename api to public [services.debug] exclude = true
Typos fail loudly instead of being ignored: an unknown setting, a schedule that isn't valid cron, or two services that would get the same name each stop the deploy with the one-line fix.
04 · 3 min
Sleeping and waking: how scale to zero works
How Greyship scales Go apps to zero: a 30s grace window, pause instead of stop, waking during the TLS handshake, scaling out under load, and $0 while idle.
Idle costs $0 by default. That's the promise, and it's only worth something if waking up is fast. This is how a Greyship app goes to sleep and comes back.
The grace window
Other serverless platforms freeze your process the moment a response is sent, which kills any goroutine still working. Greyship waits. After the last in-flight request finishes, the instance stays running for a grace window, 30 seconds by default and configurable per service. Background work finishes, and a follow-up request lands on a warm process.
Pause first, stop later
When the grace window ends, the instance is paused, not stopped. Its memory stays where it is; it just gets no CPU. Billing counts running time only, so a paused app costs nothing. Paused instances are stopped only when the server needs the memory (least recently used first) or after 24 hours idle. Most wake-ups are therefore resumes, not cold starts.
Waking on the handshake
Every HTTPS connection starts with a TLS handshake, and the very first message names the host. The edge router uses that moment: as soon as it sees api.acme.com in the handshake, it starts resuming the service. By the time the encrypted request arrives, the app is usually already running.
If nothing is running or paused, the router holds the request (up to 30 seconds) while a fresh instance starts, then forwards it. Your users see a slower first response, never an error.
Scaling out under load
Each instance handles up to concurrency requests at once (100 by default). If every instance stays above that for a couple of seconds, the router asks for another one, up to max_instances. All copies share one URL. When traffic drops, they pause one by one.
| Setting | Default | What it controls |
|---|---|---|
| grace | 30s | How long an instance keeps running after its last request |
| concurrency | 100 | In-flight requests per instance before another one starts |
| max_instances | plan limit | The most copies of a service that can run at once |
| min_instances | 0 | Copies kept running even with no traffic |
Workers and crons sleep too
Background services follow the same rule: they're woken by events, never kept running. A worker wakes when a message lands on its built-in queue, when another service calls worker.internal, or on a schedule; it goes back to sleep once the queue is empty and the grace window passes. A cron starts at its scheduled time, runs, and is stopped.
05 · 2 min
Every grey command, under the hood
What each grey CLI command does behind the scenes: login, init, deploy, dev, logs, env, domains, rollback, pprof and more, and which parts of Greyship they touch.
The grey CLI is a single static Go binary. It is a client of Greyship's public API, the same API the dashboard uses, so nothing the CLI does is hidden from the dashboard or the other way round. The installer also creates gray and greyship, so every spelling works. Every command accepts --json for scripts, and GREYSHIP_TOKEN makes it work in CI.
Account
| Command | What happens under the hood |
|---|---|
| grey login | Asks the API for a one-time device code, opens your browser and shows the code. You approve it while signed in; the CLI polls every 5 seconds and receives a token for one team. The token is saved in your user config folder, readable only by you. |
| grey logout | Revokes the token on the server first, then forgets it locally. A stolen copy stops working too. |
| grey whoami | Shows the account and team behind the current token. |
| grey teams | Lists the teams you belong to and your role in each. |
| grey tokens | Creates, lists and revokes API tokens for CI and scripts. A new token is shown once; only its hash is stored. |
Projects
| Command | What happens under the hood |
|---|---|
| grey init | Optional. Creates a project named after your go.mod module (or a name you choose) and writes .greyship/project.json, a small file with no secrets that you commit. |
| grey projects | Lists your team's projects and marks the one this folder is linked to. |
| grey deploy | Links the project if needed, diffs .env.prod by hash, uploads your source, builds every service in a sandbox, runs the release step and switches traffic. Full walkthrough. |
| grey rollback | Points traffic back at an earlier deployment. Nothing is rebuilt and the release step doesn't run again, so it takes effect at once. |
| grey open | Opens the service's URL in your browser. |
Running and observing
| Command | What happens under the hood |
|---|---|
| grey dev | Runs the whole project on your machine the way Greyship would: detects services, builds each one, starts them on local ports, loads .env then .env.local, and makes <service>.internal resolve locally. Rebuilds a service when its files change. |
| grey ps | Lists services and their instances: running, paused or stopped. |
| grey logs -f | Streams stdout and stderr from every instance, with JSON logs pretty-printed. Add --service to narrow it. |
| grey pprof | Takes a CPU, heap or goroutine profile from a live service, if it imports greyship.dev/agent. The profile travels over the sandbox's side channel, not your app's port. |
Env vars and domains
| Command | What happens under the hood |
|---|---|
| grey env set / rm / ls | Writes, removes or lists the project's main variables. Values are encrypted before they're stored, and only the affected services restart. |
| grey env push | Loads a whole file (default .env.prod), shows a key-only diff and asks before sending the changed values. |
| grey env pull | The only command that downloads stored values. Writes .env.prod and asks before overwriting an existing file. |
| grey domains add | Adds a custom domain, tells you the CNAME or A record to create, verifies it and gets a certificate automatically. |
06 · 3 min
How your secrets are protected
How Greyship handles env vars and secrets: compared by hash, encrypted with envelope encryption, decrypted only at instance start, scoped per service, never logged.
Your database password, your Stripe key, your signing secrets: a platform that runs your code has to hold them. This is exactly how Greyship does it, from the file on your laptop to the process that uses them.
Your env files
| File | Used by | Leaves your machine? |
|---|---|---|
| .env | grey dev | Never |
| .env.local | grey dev (overrides .env) | Never |
| .env.prod | grey deploy | Only changed values, only to the encrypted store |
| .env.preview | preview deploys | Only changed values, only to the encrypted store |
If one of these files is tracked by git, the CLI warns you loudly and suggests adding it to .gitignore. Deploys triggered from GitHub never read env files at all; they use the variables already stored for the project.
Compared by hash, sent only when changed
On every deploy the CLI downloads key names and hashes of the stored values, never the values themselves, and hashes your .env.prod locally. It shows a key-only diff and sends only added and changed values, over TLS, to the env endpoint. The only way to download stored values is grey env pull, and it asks before overwriting a file.
Encrypted at rest, twice over
Greyship uses envelope encryption. Every value is encrypted with your team's data key. That data key is stored only in encrypted (wrapped) form, sealed by a master key kept outside the database. Someone with a copy of the database has ciphertext and a wrapped key, and nothing that opens either.
In the dashboard values are masked, and revealing one requires signing in again.
Decrypted at one moment, in one place
Secrets are decrypted only when an instance starts, by the node agent on that server, and handed to the sandbox's first process over a local channel. They are never decrypted by the API, never visible to builds, never written to logs or error messages, never included in API responses, and never stored in a snapshot in plain text: snapshots are taken before secrets are injected.
Each service gets only what it needs
Variables have two layers. Main variables go to every service in the project. Service overrides go to one service only. A service receives the main variables plus its own overrides, and never another service's, so payments can hold a key that api can't read. A service can also exclude a main key entirely. The agent only ever decrypts the rows in that service's effective set.
Tokens and logins
- There are no passwords. You sign in with a one-time code sent to your email, or with Google or GitHub.
- Sessions and API tokens are random values stored only as SHA-256 hashes. A token is shown once, when it's created.
- Every token belongs to one team and only works inside it.
grey logoutrevokes the token on the server, not just on your machine.
07 · 2 min
Isolation and networking
How Greyship isolates every Go app: each instance in its own gVisor sandbox or Firecracker microVM, per-tenant networks, private service-to-service calls, HTTPS everywhere.
Greyship treats every app and every build as untrusted, including yours, because the same rules protecting you from other tenants protect them from you. Nothing a user uploads ever runs directly on a host.
One sandbox per instance
Every instance and every build runs in its own sandbox, either a gVisor sandbox or a Firecracker microVM. gVisor puts a user-space kernel between your app and the real one; a microVM gives it a minimal kernel of its own. Either way your process never talks to the host kernel directly.
- The root filesystem is your binary on a minimal base, mounted read-only.
/tmpis a small in-memory scratch space. - Hard limits on memory, CPU and number of processes, and
GOMAXPROCS/GOMEMLIMITset to match them. - Builds run in a fresh sandbox per job that can reach only the Go module cache.
The network
- Each instance gets its own network namespace on a per-tenant network.
- Inbound traffic is accepted only from Greyship's routers. Instances never talk to each other directly.
- Traffic to other tenants, the host, cloud metadata and the internal network is dropped.
- Outbound internet works through NAT, so you can call any API or database. Port 25 is blocked and connection rates are limited to stop abuse.
Private services and .internal names
Services in the same project reach each other at http://<service>.internal, for example http://orders.internal. Hard-coding that address in your code is safe: it only resolves inside your project, and grey dev resolves it on your laptop too.
Those calls go through an internal router that only accepts traffic from instances of the same project and never from the internet. It applies the same wake, hold and scale logic as the edge, so a private service can scale to zero and still answer the first internal call. Mark a web service visibility = "private" and it gets no public URL at all. HTTP/1.1 and h2c (gRPC, Connect) work end to end.
HTTPS everywhere
Every public service gets <service>-<project>.greyship.app with a certificate, and custom domains get theirs automatically through ACME, renewed without you thinking about it. Plain HTTP redirects to HTTPS by default. Greyship's own components talk to each other over a private network with mutual TLS.
08 · 1 min
What you pay for
Greyship bills Go apps per second of running time. Idle apps cost $0, spend caps are on by default, and always-on is an explicit, clearly labelled opt-in.
Serverless only means something if the bill follows the work. Greyship charges for the time your code is actually running, not for capacity you reserved in case.
What's metered
- Running time, per second: vCPU-seconds and GB-seconds while an instance is running.
- Requests: public and internal requests are counted the same way.
- Egress: data your apps send out to the internet. Traffic between your own services is free.
Paused and stopped instances aren't running, so they aren't billed. The grace window after the last request counts as running time, since your goroutines are still working in it.
Idle costs $0
Nothing keeps running while idle unless you ask for it. A service with min_instances = 1 stays warm and is labelled always on · billed 24/7 everywhere it appears. The same goes for open WebSockets: a connected socket keeps its instance awake, and the pricing page says so.
Spend caps are on by default
Every team has a spend cap from day one. If usage reaches it, services pause and the owner gets an email. A bad loop or a traffic spike can't run up a bill you didn't agree to.
Plans and payment
There is a free plan that needs no card, a Pro plan with usage included, and a Team plan per seat. Pay in US dollars with Stripe, or in naira with Paystack (cards and bank transfer); naira prices are set locally, not converted live. See pricing for the current numbers.
Questions and comments
Something unclear, or a case we haven't covered? Ask. The team answers here.
Loading comments…