Skip to content

Architecture

Apply phases

Before the phases run, apply prints a plan (the same one managed plan shows) and acquires a local lock (.managed/state.lock) so two applies in the same project can't corrupt state. If the plan would destroy a server, apply stops and asks for confirmation unless --allow-destroy/--force is set; it also snapshots state for --rollback.

When you run managed apply, the platform executes these phases in order:

1. Provision storage

Auto-create an S3-compatible bucket if any service uses backup schedules. Credentials are stored in state and made available to service scripts via managed.storage.

2. Create servers

Create servers that don't exist yet. Each node role in each service gets its own server(s). The platform calls the configured cloud provider's API, waits for the server to become active, and records its IPs in state.

Servers that already exist are left untouched.

Named servers, when a service sets server: my-box, managed uses that name instead of generating a random one. If multiple services reference the same server, it is created once and shared.

External servers, when a server entry has ip set (bring your own), managed skips creation entirely and imports the server into state with the given IP. External servers are marked in state so they are never auto-destroyed.

3. Install

Run install action scripts on new nodes via SSH. This phase is skipped for nodes that have already been installed (tracked in state).

Install scripts typically set up packages, write initial config files, and enable services.

4. Configure

Run configure action scripts after all installs complete. Configure scripts can use managed.state.getServer() to discover other nodes, since all IPs are known by this point.

This phase re-runs a node only when its configuration actually changed. Managed fingerprints each node's resolved params plus the internal IPs of the peers it depends on (a config hash); when a peer's internal_ip flips or a param changes, the hash changes and configure re-runs to regenerate dependent config. In steady state nothing changes, so nothing re-runs.

When several nodes of a role need reconfiguring at once, they roll out in batches (default one at a time) with a readiness gate between batches, so a warm-up-sensitive role isn't all restarted together. Package authors tune this with rolling_update and depends_on (see Service packages).

4b. Fleet placement

Services that declare scope: fleet (e.g. a metrics agent) are placed on every server, after the server list is known. A fleet service provisions no servers of its own and never takes ownership of the boxes it rides, it just installs alongside whatever else runs there. New servers pick up fleet services automatically on the next apply.

5. Firewall

Compute and apply firewall rules from port declarations in service.yaml. The platform translates from: scopes (service, private, public/any, or another service's name) into the matching UFW rules.

Rules are additive and idempotent, re-running doesn't duplicate rules.

6. Schedules

Install crontab entries from schedule declarations. The platform translates every: 6h into the appropriate cron expression and writes it to the node's crontab.

7. Scale down

Handle servers that are no longer in the desired topology. For example, if you reduce replicas: 2 to replicas: 1, the extra replica is removed.

Servers are only destroyed when destruction was authorized (a confirmed plan, --allow-destroy, or --force); otherwise they're left running with a warning. Servers marked protect: true are never auto-destroyed. External and adopted servers (managed didn't create them) are never destroyed, the service link is dropped but the server keeps running.

8. Reconcile removals

Tear down services that still have state on boxes but are gone from managed.yaml. A scope: fleet or co-located service (one that runs on boxes it doesn't own) is uninstalled in place, its uninstall action runs, its firewall rules are removed, and its state is cleaned, while the box keeps running. Servers a removed service provisioned are left running, with a note to run managed service destroy (apply never auto-destroys hardware just because config was edited).

State

Everything is tracked in .managed/state.json:

  • Server details, IPs (public and private), provider IDs, SSH users, external flag, protect flag
  • Service ownership, which service provisioned each server (a server has one owner; this drives scale-down and destroy)
  • Service instances, which services run on each server (a box can run several, this is how scope: fleet and co-located services are tracked)
  • Generated credentials and applied config, passwords, replication keys, and the config each service was applied with (so a later teardown closes the right firewall port)
  • Completed actions, install, configure, firewall, schedules (for idempotency; the configure marker carries a config hash so a peer-IP change re-runs it)
  • Storage credentials, auto-provisioned bucket details
  • Repository state, cached repos, URLs, refs, last fetched timestamps

State is local to the project (.managed/state.json) and can be synced to cloud storage for team use. Alongside it, .managed/state.lock guards against concurrent applies and .managed/state.prev.json is the snapshot managed apply --rollback restores.

The document carries a schema_version so different managed versions can share a project safely: an older document is upgraded automatically on load, and a document written by a newer managed is refused with a clear "update managed" message instead of being mis-read (which could silently drop fields the older binary doesn't know about). This applies to both the local file and the copy fetched from the cloud.

Provider abstraction

Cloud providers implement a Go interface that covers server lifecycle operations:

  • CreateServer / DestroyServer
  • GetServer
  • EnsureSSHKey
  • ListRegions / ListSizes

This abstraction lets service packages work identically across providers. A service script doesn't know or care whether it's running on DigitalOcean, Hetzner, Linode, or OVH, it uses managed.state.getServer() and gets back IPs either way.

Private networking

Servers reach each other over a private address, never the public internet. The platform fills in internal_ip on each server and service scripts use it transparently, via a single source of truth (ResolveInternalIP) with this precedence:

  1. WireGuard overlay — a full-mesh tunnel on 10.100.0.0/24 that spans providers and regions, so a box on DigitalOcean and one on OVH still share a private path.
  2. Provider private network — when two servers share one provider account and region.
  3. Loopback — co-located services on one box.

This address is also surfaced to you, not just to scripts. See Networking & DNS for how connection strings show the overlay address, the internal *.managed.internal names that resolve it, and the public *.by-managed.works scheme.