Install
One command on a VPS, or a clone and npm run dev on your laptop.
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
curl -fsSL https://raw.githubusercontent.com/InsForge/instacloud-oss/main/install.sh | sudo sh
It pulls 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 with
INSTA_OSS_IMAGE:
curl -fsSL https://get.docker.com | sudo sh # only when the box has no Docker yet
git clone https://github.com/InsForge/instacloud-oss.git
cd instacloud-oss
sudo docker build -t instacloud:dev .
sudo INSTA_OSS_IMAGE=instacloud:dev sh install.sh
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 enable
ufwyourself: 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:sshd -T | awk '/^port /{print $2}' systemctl show ssh.socket sshd.socket --value -p Listen ss -tlnpH | grep -i ssh -
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.
Then it prints where to go. This is a real run on a 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:
InstaCloud is running.
Setup: https://console.203-0-113-10.sslip.io/setup
API: https://api.203-0-113-10.sslip.io
Note: 203.0.113.10 is this cloud VM's public address, mapped by the provider: make sure its security group or firewall allows 80 and 443 (and the database lanes you use).
CLI: insta login --api-key <token from the setup page> --api-url https://api.203-0-113-10.sslip.io
Config /etc/instacloud Data /var/lib/instacloud Reflinks: loop image (10 GiB)
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.
Check it answers: curl -s https://api.<domain>/healthz returns {"ok":true}.
What it writes
| Path | What |
|---|---|
/etc/instacloud/instad.env | every INSTA_OSS_* setting, mode 600 |
/etc/instacloud/compose.yml | the three services io-instad, io-edge, io-garage |
/etc/instacloud/Caddyfile | the edge configuration: on-demand TLS under acme and internal, and under custom the supplied pair with no on-demand machinery at all |
/etc/instacloud/.env | a symlink to instad.env, so a plain docker compose in that directory interpolates the same values |
/var/lib/instacloud | all data: state.json, pg/, vol/, md/, garage/, caddy/, edge/ca.pem |
/var/lib/instacloud.img | the XFS loop image, only when one was needed |
/etc/fstab | /var/lib/instacloud.img /var/lib/instacloud xfs loop,nofail,x-systemd.required-by=docker.service,x-systemd.before=docker.service 0 0 |
/etc/docker/daemon.json | default-address-pools, when the file is missing or empty |
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
| Flag | Env | What |
|---|---|---|
--domain <host> | INSTA_OSS_DOMAIN | your own apex, instead of the automatic sslip.io name |
--email <addr> | INSTA_OSS_ACME_EMAIL | the ACME account address. Written into the Caddyfile under acme and internal, and omitted under custom, which contacts no issuer |
--version <vX.Y.Z> | INSTA_OSS_VERSION | pin the image; omit for the newest release |
INSTA_OSS_IMAGE | the daemon image, name or name:tag; a tag here is the version. Use it to run an image you built yourself | |
--tls acme|internal|custom | INSTA_OSS_TLS | public certificates, an internal issuer for a private box, or one wildcard you supply. See TLS and what your domain costs you |
--tls-cert <path> | INSTA_OSS_TLS_CERT_FILE | with --tls custom: the certificate to serve, which must carry *.<domain> and *.s3.<domain> |
--tls-key <path> | INSTA_OSS_TLS_KEY_FILE | with --tls custom: its private key. Both are required together |
--data-img-gib <n> | INSTA_OSS_DATA_IMG_GIB | size of the loop image when one is created |
--data-dir <path> | INSTA_OSS_DATA_DIR | required to change the data directory of an existing install |
--print-firewall | print the firewall rules and exit | |
--print-daemon-json | print the Docker address-pool block and exit | |
--print-env, --print-compose, --print-caddyfile | print the file and exit | |
--print-ssh-advice | print which ports your SSH daemon listens on, to allow before you enable a firewall, and exit | |
--print-domain-note | print whether the automatic domain resolves to this box, and exit | |
-y | assume yes |
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.
curl -fsSL https://raw.githubusercontent.com/InsForge/instacloud-oss/main/install.sh | sudo INSTA_OSS_IDLE_COMPUTE_SEC=900 sh -s -- --domain example.com --email ops@example.com
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.
{ "default-address-pools": [ { "base": "10.100.0.0/14", "size": 24 } ] }
That is 1024 /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 streams pg_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.
Check afterwards:
findmnt -no FSTYPE /var/lib/instacloud # xfs
xfs_info /var/lib/instacloud | grep reflink=1
Grow the loop image later:
truncate -s +20G /var/lib/instacloud.img
losetup -c "$(findmnt -no SOURCE /var/lib/instacloud)"
xfs_growfs /var/lib/instacloud
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 real
cp --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.
zpool get feature@block_cloning rpool # active | enabled | disabled
cat /sys/module/zfs/parameters/zfs_bclone_enabled # 1 = on
Enabling it is a pool upgrade plus the module parameter, and it is your call whether to take that on: read the OpenZFS release notes for the version you run first.
The ARC is counted as available memory. The daemon evicts sleeping services when free memory
falls under a floor (see sleep). On Linux the ZFS ARC is not page cache, so
the kernel's own 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.
--tls | What the edge serves | What gets published |
|---|---|---|
acme (default) | a certificate per hostname, issued on demand by Let's Encrypt | every hostname you deploy, to the public certificate transparency logs |
internal | a certificate per hostname from Caddy's own CA | nothing, but no browser trusts it without installing the root |
custom | one certificate you supply, for *.<domain> and *.s3.<domain> | nothing |
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.
That is not a warning about a risk; it is what happens. Use it to try the product, and use a domain of your own for anything you leave running.
--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.
sh install.sh --domain example.com --tls custom \
--tls-cert /etc/instacloud/tls/wildcard.crt \
--tls-key /etc/instacloud/tls/wildcard.key
The certificate must carry two SANs, *.<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:
subjectAltName = DNS:*.example.com, DNS:*.s3.example.com, DNS:example.com
The installer checks all of it before it starts anything: the PEM has to parse, the key has to
match the certificate, it must be inside BOTH its validity dates, it must carry both wildcards
(sampling a few names cannot promise the arbitrary future hostnames this mode exists to serve),
and it must cover 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:
# your tool writes wildcard.crt.new / wildcard.key.new into /etc/instacloud/tls, then: mv -f /etc/instacloud/tls/wildcard.crt.new /etc/instacloud/tls/wildcard.crt mv -f /etc/instacloud/tls/wildcard.key.new /etc/instacloud/tls/wildcard.key cd /etc/instacloud && docker compose --env-file instad.env restart edgeThe 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's
live/<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, and
GET /healthzcarries the remaining lifetime unconditionally so you can alert on your own margin:curl -s https://api.example.com/healthz # {"ok":true,"certificate":{"notAfter":"2027-03-01T00:00:00.000Z","daysLeft":172,"secondsLeft":14860800}}The
certificateobject 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.
Moving back is a re-run: sh install.sh --tls acme drops the supplied pair and restores
on-demand issuance.
Uninstall
Stop the stack and keep every byte of data:
cd /etc/instacloud && docker compose down
Re-running the installer brings it back with the same domain, the same secret and the same data.
Remove the whole install instead, data included. This deletes every project on the box:
cd /etc/instacloud && docker compose down
docker ps -aq --filter name=io- | xargs -r docker rm -f
docker network ls -q --filter name=io- | xargs -r docker network rm
rm -rf /etc/instacloud
umount /var/lib/instacloud 2>/dev/null
sed -i '\|/var/lib/instacloud|d' /etc/fstab
rm -rf /var/lib/instacloud /var/lib/instacloud.img
The 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:
git clone https://github.com/InsForge/instacloud-oss.git && cd instacloud-oss
npm install
npm run build:ui # the dashboard, served by the daemon itself
npm run dev # the daemon on http://127.0.0.1:8080
Then, in another terminal:
npm install -g insta
export INSTA_API_URL=http://127.0.0.1:8080
insta status # user: local
Prerequisites are Docker (running) and Node 22 or newer. The first run pulls the Postgres, Garage
and rclone images. State lives in ~/.insta-oss.
App URLs in local mode are http://<group>-<project>-<branch>.localhost:8080.