Requirements
Server mode wants a machine you can point DNS at:- Ubuntu 22.04+ or Debian 12+,
x86_64oraarch64. The daemon image and every bundled template publish both, andinsta template listreports each template’sarchitecturesnext to this box’s ownhostArchitecture, so a template that will not run here is refused before it creates anything rather than failing on the image pull - root: pipe the one-liner to
sudo sh(the script itself callssudofor nothing, and a non-root run stops at once with the command to use) - inbound TCP 80 and 443 allowed in the cloud firewall or security group, and 5432 only if Postgres clients outside the box need it. Keep Redis (6379), MongoDB (27017) and the MySQL lane range closed at the provider: those lanes are for containers on the box, and the installer’s own firewall rules leave them private. It opens a host firewall it finds active, but it cannot see your provider’s, and certificates are issued through 80 and 443
- 2 vCPU, 2 GiB RAM, 15 GiB free disk. A first install refuses a box under the memory or disk minimum and says which one; an upgrade of a box that is already installed warns and continues. One vCPU is a warning either way: it runs, and every deploy and fork is slow
- ports 80, 443, 8080, 8081, 5432, 6379 and 27017 free (the script refuses to start if any is
bound, and prints the holder). Those are the defaults; the refusal names the
INSTA_OSS_LANE_*key that moves each lane, and everything the installer then checks, opens, writes and prints follows the value you set - Docker, installed by the script when it is missing
The one-liner
ghcr.io/insforge/instacloud, published for linux/amd64 and linux/arm64.
With no --version and no tag on INSTA_OSS_IMAGE, the installer asks GitHub for the newest
published Release and installs that, on a first install and on an upgrade alike. The mutable
latest tag is only the fallback, used when that lookup returns nothing. Pin a specific one by
ending the one-liner with | sudo sh -s -- --version v0.1.0: flags go after sh -s --, because the
script arrives on stdin. (The --print-* modes skip the lookup, so the version they print is the
fallback rather than what a real run would choose.)
Build the image yourself
You do not need to: the image is published. This is for running a build of your own, from a patch or an unreleased commit. Build it on the box and point the installer at it withINSTA_OSS_IMAGE:
INSTA_OSS_IMAGE may carry a tag, and that tag is the version, so there is nothing else to pin.
The installer pulls with pull failures ignored, finds the tag already in the local Docker, and
starts it. The upgrade is the same commands again: pull the branch, rebuild the tag, re-run the
script. The build needs about 1 GiB of free memory and a minute on a 2 vCPU box.
What the script does
-
Renders and exits for any
--print-*flag, with no root and no side effects. -
Checks preconditions: root, Linux, a supported architecture. It installs
curlandiproute2when the box does not have them, before it resolves anything, because it needscurlfor the release tag and the public address andipandssfor the address fallback and the port check. An existing/etc/instacloud/instad.envputs it in upgrade mode. -
Checks ports and refuses when any of them is taken, naming the holder: 80 and 443 for the edge,
8080 and 8081 for the daemon, and 5432, 6379 and 27017 for the three database lanes. The daemon
binds all three lanes at startup and exits if one is busy, so a busy lane port is a refusal, not
a warning; the message names the
INSTA_OSS_LANE_*key that moves that lane elsewhere. While the stack is already up this check is skipped. -
Checks the sizing: the vCPU count,
MemTotal, and the free space on the filesystem that will hold the data directory. See Requirements for what is fatal and what is a warning. -
Installs Docker when missing, enables it, requires
docker compose version, and starts the daemon when it is installed but not answering. -
Writes
/etc/docker/daemon.jsonwithdefault-address-poolsand restarts Docker when the file is missing or empty. Adaemon.jsonthat already holds other settings is left untouched, with a warning that prints the key to add: until you add it and restart Docker, the box holds only 31 branch networks. See Docker address pools for why. -
Opens the firewall, but only when one is active, and only for its own services (see below:
never for SSH). Under
ufw: inbound 80, 443 and the Postgres lane, plus 443, the three database lanes and the per-service lane range fromdocker0and from every Docker address pool. The ports come from the values it resolved, so a lane you moved is the lane it opens; the numbers below are what those resolve to on a default install (5432, 6379, 27017 and 20000 to 20999). Underfirewalld: the same container ports in thedockerzone and 80, 443 and 5432 in thepublicone. Both are idempotent and every rule is printed.--print-firewallshows the list without touching anything. The installer never adds, edits or removes an SSH rule. It opens the ports for the services it installs and nothing else: your SSH policy is yours, and a script that edits it either skips a rule your box needed or widens one you narrowed on purpose. What it does do is tell you, when it finds no active firewall, which ports to allow before you enable one. It reads three sources and takes the UNION of them, because they answer different questions and none is authoritative:sshd -T(the effective sshd config),systemctl show ssh.socket sshd.socket -p Listen(socket activation, which is the Ubuntu 24.04 default) andss -tlnpH(what is listening now). Allow EVERY port any of them names. Take that seriously if you enableufwyourself: Ubuntu’sufwdefaults its input policy toDROP, so enabling it without a rule for every portsshdlistens on cuts you off. Quietly, too, because established connections keep working and the lockout only shows on the next reconnect or reboot.sshd -T | grep '^port 'on its own is NOT the check, and this is the trap the union exists for: the documented way to move SSH on Ubuntu 24.04 issystemctl edit ssh.socketwithListenStream=2222, which leavessshd_configat#Port 22, sosshd -Tanswers 22 on the box that moved it. Ask all three, and allow all three answers: -
Creates
/var/lib/instacloudand probes for reflinks, falling back to an XFS loop image. See Reflinks. -
Resolves the domain:
--domain, thenINSTA_OSS_DOMAIN, then the value already ininstad.env, then automatically from the public IPv4 address as<a-b-c-d>.sslip.io. -
Writes the secrets and files:
INSTA_OSS_SECRET(kept if present, else 64 hex characters), the Garagerpc_secretand toml,instad.env(chmod 600),compose.ymland theCaddyfile. -
Pulls and starts the stack:
docker compose --env-file instad.env up -d --remove-orphans. -
Waits on
http://127.0.0.1:8080/healthzfor up to 60 seconds. On timeout it prints the tail ofdocker compose logs instadand exits 1. -
Checks the certificate, in the way that mode calls for. Under
acmeandinternalit asks the edge forapi.<domain>over loopback and waits until the file appears in the store, up to 4 minutes with a public CA and 60 seconds with the internal issuer. A certificate that has not landed by then is a warning, not a failure: the edge keeps retrying on its own, and the database lanes start presenting it the moment it lands. WithINSTA_OSS_TLS=internalthe root CA is then copied to/var/lib/instacloud/edge/ca.pem. Undercustomthere is nothing to wait for, so it probes instead: the edge has to answerapi.<domain>with the SHA-256 fingerprint of the certificate you supplied, and a pair the edge cannot load fails the install rather than leaving it crash-looping behind a success message.
t3.small (2 vCPU, 2 GiB) with Ubuntu 24.04,
start to finish in about a minute, with the address replaced by a documentation one:
Setup:is where you create the admin account. See set up access.API:is what the CLI, MCP and agents talk to.Note:appears when the box’s public address is mapped by a cloud provider or NAT. Act on it before anything else: the certificates for your URLs are issued through 80 and 443.CA:appears with--tls internal, naming the root clients must trust.CLI:is the login line, once you have a token.
curl -s https://api.<domain>/healthz returns {"ok":true}.
What it writes
The
x-systemd.required-by=docker.service part matters: Docker must not start before the data
mount is there. Without it a reboot where the image failed to mount would let dockerd restart the
Postgres containers against empty directories.
Options
Every
INSTA_OSS_* variable the script runs with is passed through into instad.env, so the idle
and sleep knobs can be set at install time. They are all listed on the
sleep and wake page. Put them after sudo, as below: sudo resets the
environment it inherits, so a variable you only export in your shell never reaches the script, and
the install succeeds with the defaults instead.
Docker address pools
Every branch gets its own user-defined Docker network, and stock dockerd can only hand out 31 of them:172.17.0.0/16 through 172.31.0.0/16 minus docker0, plus 16 /20 networks under
192.168.0.0/16. Without a wider pool the 32nd insta branch create answers 507 with
docker has no free network subnets.
/24 networks. The installer writes it only when /etc/docker/daemon.json is missing
or empty. A box that already sets the key keeps its own value, and when the file carries other keys
but not this one the script leaves it untouched and prints the block for you to merge by hand.
Reflinks
Branch creation copies the Postgres data directory and every volume with a reflink copy: the kernel shares the blocks and only writes diverge, so a fork of a 50 GiB database that is asleep is a sub-second metadata operation. A database that is awake streamspg_basebackup instead, in time
proportional to its size. XFS with reflink=1 and Btrfs support reflinks. ext4 does not.
The script probes with cp --reflink=always on two temporary files inside the data directory:
- Probe succeeds: native reflinks, nothing else to do.
- Probe fails and the data directory is empty: the script installs
xfsprogs, checks for at least 15 GiB free, creates/var/lib/instacloud.img(--data-img-gib, or free space minus 5 GiB, at least 10 GiB), formats it withmkfs.xfs -m reflink=1, adds the fstab line and mounts it. - Probe fails and the data directory already has data: the script warns and continues. This is not
an install failure. The daemon boots, logs one warning, and branch forks stream
pg_basebackupinstead. They are correct, just proportional to the database size.
ZFS
ZFS works, and nothing here needs configuring for it. Two things are worth knowing. Branching uses native cloning only when block cloning is on. The probe is a realcp --reflink=always, not a list of filesystem names, so a pool with block cloning enabled gets
sub-second forks the same way XFS and Btrfs do, and a pool without it falls back to
pg_basebackup and a file copy. Block cloning arrived in OpenZFS 2.2 and ships DISABLED by
default in the 2.2 series after an early data-corruption bug, which includes the 2.2.x that Ubuntu
24.04 carries. So a stock Ubuntu 24.04 ZFS root probes as no-reflink, and on an empty data
directory the installer then builds its XFS loop image on top of ZFS. That works, and it is two
layers of copy-on-write with the ARC caching the image underneath. If you would rather have one
layer, turn block cloning on before installing, or pass --data-dir to a dataset you are happy to
keep as ZFS and accept streaming forks.
MemAvailable does not count it even though the ARC gives memory back under
pressure. The daemon reads /proc/spl/kstat/zfs/arcstats and adds back the part the ARC will
release (size minus c_min), so a warm ARC does not read as a full machine. You do not need to
cap zfs_arc_max to keep the daemon from evicting; cap it if you want the ARC smaller for its own
sake.
TLS and what your domain costs you
Three modes, and the choice decides whether scale-to-zero holds for public compute services.The cost of the auto sslip.io domain
If you do not pass--domain, the installer derives one from your public IPv4 address
(<a-b-c-d>.sslip.io) and --tls acme then issues a certificate per hostname. That is an
evaluation convenience, and it has a measured consequence:
- Certificate transparency is public and indexed. Every service you deploy publishes its exact hostname within minutes of its first request.
- Scanners read those logs. On a live box, a freshly issued service hostname drew 141 requests
from a credential scanner probing
/.env,/.env.local,/config.jsand/settings.jswithin about 15 minutes, and then kept arriving every 1 to 3 minutes. - The idle timer for compute is 300 seconds and any request resets it. So a public compute service on this configuration never sleeps. Scale-to-zero still holds for databases, whose lanes are not discoverable this way, but not for the thing you point a browser at.
--tls custom: a wildcard you supply
With a certificate for *.example.com the edge serves it for every name under it and issues
nothing, which is what the managed cloud does. No hostname is ever published, so nothing arrives
to find it, so an idle compute service reaches its timer and sleeps.
*.<domain> and *.s3.<domain>, because a wildcard
matches exactly one label and this box serves two depths. Every service name is one label
(api.<domain>, console.<domain>, web-<ref>.<domain>, pg-db-<ref>.<domain>,
s3.<domain>), and every bucket is two: <bucket>.s3.<domain>, which is how the AWS SDKs
address a bucket by default and what AWS_ENDPOINT_URL_S3 resolves to inside every deployed
app. In the other two modes those names get a certificate of their own on demand. This mode
issues nothing, deliberately, so one certificate has to cover them. Ask your CA for both on the
same certificate:
api.<domain> and console.<domain>. A pair that cannot serve fails the
install rather than leaving the edge crash-looping behind a success message. The post-install
probe is part of that: the edge has to answer with that certificate before this prints
anything reassuring.
The directory holding the pair is mounted read-only into the edge and the daemon (the same
path on both sides), not the two files, and the daemon presents the same certificate on the
database lanes, so nothing anywhere asks for a per-hostname certificate. Keep the pair in a
directory of its own, such as /etc/instacloud/tls: everything beside it is visible to both
containers. The installer refuses a directory that would collide with the stack’s own mounts,
which means the data directory, anything inside it, any directory that contains it, and the
system directories the images own.
What you take on:
-
Renewal is yours, and the daemon will not let it fail silently. Nothing renews a supplied
certificate. Write the new pair into the same directory and move it over the old names, which
is what renewal tooling does:
The daemon needs nothing: its lanes re-read the file on the next handshake that sends SNI (every current client), the default they hand a client that sends none (libpq before 14, older JDBC, which are then told to send it) follows on the daemon’s next beat, and
healthzreports the new certificate within that same sweep interval (30 s by default,INSTA_OSS_SWEEP_SEC). That lag is deliberate:/healthzis public and unauthenticated, so the request path reads no file and makes no syscall, and the daemon’s own beat is what looks at the disk. Both were measured after a real rename. The edge needs its process restarted, because Caddy loads certificates when it loads its config and does not watch the file. Nothing needs the containers RECREATED, which is why the directory is what gets mounted: a bind-mounted FILE stays attached to the inode it resolved at mount time, so a rename over it would be invisible inside the container and the edge would keep serving the old certificate until it expired. Symlinks are followed: certbot’slive/<domain>/fullchain.pempoints intoarchive/<domain>/, and the installer mounts both directories, so the link resolves inside the containers and a renewal that re-points it stays visible. Point--tls-certat thelive/path, not thearchive/one. ONE link is followed, because only the link’s directory and its target’s are mounted: a link to another link is refused, since the middle hop would not exist inside the containers. A symlinked directory on the way (an/etc/letsencryptkept on another volume) is fine; the target is mounted at the path the link leads to. The daemon logs a warning at boot and every few hours once the certificate is inside 21 days, says something louder once it has expired, andGET /healthzcarries the remaining lifetime unconditionally so you can alert on your own margin:Thecertificateobject is absent in the other TLS modes, and absent if the file cannot be read, which is worth alerting on too. -
A custom domain outside your wildcard has no certificate.
insta domain attachstill attaches the hostname and routes it, andinsta domain checkreports it aspendingrather thanreadybecause the certificate does not cover it. Add it as a SAN to the certificate you supply, or keep that name on--tls acme. -
Issuance for the wildcard itself is not automated here. A DNS-01 wildcard order needs a
Caddy build with your DNS provider’s plugin, and the stock
caddy:2.11.4image this stack runs has none. Get the certificate however you already get certificates and hand it over.
sh install.sh --tls acme drops the supplied pair and restores
on-demand issuance.
Uninstall
Stop the stack and keep every byte of data:umount and the sed matter only when the installer created a loop image, and both are safe
to run when it did not. Two things are left behind on purpose:
/etc/docker/daemon.json, because taking the address pool back out needs another Docker restart. Delete the file and runsystemctl restart dockerwhen you want the stock pools again.- Docker itself, even when the installer is what put it there.
Security posture
- The daemon mounts the Docker socket. That is root on the box. Treat the admin password and every
insta_token as root credentials. - Firewall everything except 80, 443 and the database ports you actually use.
- Keep the
docker0to host rules the installer adds. Container traffic toapi.<domain>,s3.<domain>and its ownDATABASE_URLarrives at the host through the bridge gateway and traverses the hostINPUTchain, so a default-deny firewall silently drops every in-container call while the same URLs keep working from outside. - There is one admin account and no organizations. Anyone with the password or a token can do anything.
Local mode on a laptop
No installer, no domain, no auth:~/.insta-oss.
App URLs in local mode are http://<group>-<project>-<branch>.localhost:8080.
macOS and every current browser resolve
*.localhost to 127.0.0.1 on their own. Containers and
Linux hosts without systemd-resolved do not, which is why database DSNs in local mode use
127.0.0.1:<port> rather than a name. If a .localhost URL does not resolve for you, add the
name to /etc/hosts.