# Introduction This is the Cloud in a Bottle Manual. It documents the platform from the perspective of an *owner* (someone running a Cloud in a Bottle instance) and of an *app author* (someone packaging an application to run on Cloud in a Bottle). ## For owners Sections about installing an instance, running apps on it, keeping the data safe, and debugging when things go wrong. For setup docs, choose the guide that matches your desired deployment location: - [Deploying on a cloud instance](./setup/cloud_instance.md) - [Deploying on a dedicated home server](./setup/dedicated_homeserver.md) - [Deploying on a shared home machine](./setup/shared_homeserver.md) Once your instance is up, things should be mostly self explanatory, but see [Using your instance](./operation/overview.md) for usage details. Then set up [Backups](./operation/backups.md). ## For app authors Sections about how Cloud in a Bottle expects an app to be packaged: the manifest format, the runtime contract, what your container can expect from the environment, and how to integrate with the Cloud in a Bottle identity / permissions / inter-app services machinery. If you're building an app from scratch, start at [Creating an App](./creating_an_app/overview.md). If you have an existing app and want to know which knob in `cloudinabottle.toml` controls what, jump to the [App Manifest Spec](./creating_an_app/manifest_spec.md). ## Improving the docs PRs against `docs/src/*.md` in the [cloud in a bottle repo](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle) are welcome. # Project Roadmap *last updated 8/24/26* ### things that are considered mostly finished/stable *any issues in these are high a priority to fix* - compute space core - service interface - catalog + current apps in catalog - basic imbue managed spaces platform - public landing page, with public docs, and a clear onboarding flow for selfhost or managed ### things being worked on / polished up now: - great docs - great self-hosted onboarding ### roadmap (likely to happen, in no particular order): - add Cloud in a Bottle platform support for notifications (similar to how smartphones aggregate notifications from apps in one place) - get notifications working in our community chat app - have email addresses (capable of send and receive) auto-setup in new spaces - share apps with others via email address (with email magic link auth) - support LLM inference apps on linux nvidia GPU machines and macbooks. need a permission to pass through the GPU somehow. - add opt-in imbue managed services, to make onboarding easier/faster/more convenient - IP tunnels, for easier setup of self-hosted instances without a static IPv4 - S3 for backup and archive backend, auto-setup for imbue-managed instances - make it run well + install super easily on raspberry pis (even with limited memory). - create a cloud in a bottle newsletter/blog, showing off cool new stuff each ~week - create polished coding apps (VS code, claude code, imbue sculptor, imbue minds, etc) - create or find+package many more great apps! - have a free tier in our managed service, with limited memory and auto-suspending of apps or the whole space ### roadmap (speculative): - add an "inspirations" catalog channel - for apps that aren't as polished/serious but still cool to share - platform support for peering between multiple compute spaces owned by a single user, where they appear to the user as one unified space. to allow higher availability or other features # Deploying on a cloud instance Use this guide if your machine has a static public IPv4 and can be dedicated to running Cloud in a Bottle. If your machine is at home, it likely doesn't have a static IP. See [Deploying on a dedicated home server](./dedicated_homeserver.md) or [Deploying on a shared home machine](./shared_homeserver.md) instead. ## Prerequisites - **A domain name you control**, and access to its DNS settings at your registrar or DNS provider. The examples use `mycooldomain.com`. You can use a subdomain of a domain you already own (`bottle.mycooldomain.com`); everything below works the same, just substitute it throughout. - A machine with a **static public IPv4** (the examples use `203.0.113.10`), running a freshly installed **Ubuntu 24.04**. - SSH access as `root`, or as a user with sudo. - Inbound `80/tcp`, `443/tcp`, and `53/tcp+udp` reachable from the internet. 53 is required because instance runs its own authoritative DNS server. Some cloud providers put a firewall or security group in front of the machine by default, so make sure these ports are unblocked there. - A root filesystem supporting idmapped mounts (ext4, xfs, or btrfs). This is standard; install fails early with a clear error if it does not. --- ## 1. Delegate DNS to the machine Cloud in a Bottle runs an authoritative DNS server for your zone. It serves the wildcard `*.mycooldomain.com`, so every app gets a subdomain without you touching DNS again, and it answers the ACME DNS-01 challenge used to issue the wildcard TLS certificate. So you delegate the whole zone to the machine rather than pointing an `A` record at it. For zone `mycooldomain.com` on a server at `203.0.113.10`, create two records at your DNS provider: | Type | Name | Value | |------|------------------------|------------------------| | `A` | `ns1.mycooldomain.com` | `203.0.113.10` | | `NS` | `mycooldomain.com` | `ns1.mycooldomain.com` | The `A` record is glue. An `NS` record can only name a host, not an IP, so something has to resolve `ns1` first. Some registrars express this as "custom nameservers" for the domain rather than as an `NS` record you edit directly; either way you want `mycooldomain.com` delegated to `ns1.mycooldomain.com` at `203.0.113.10`. ## 2. Check the delegation Do this before installing. Delegation changes can take anywhere from a minute to a day to propagate, depending on TTL on the previous record, and the install acquires a certificate over DNS-01, which will fail if the zone isn't yet delegated. ```bash dig +short NS mycooldomain.com # -> ns1.mycooldomain.com. dig +short A ns1.mycooldomain.com # -> 203.0.113.10 ``` ## 3. Install SSH to the machine as root and run: ```bash curl -fsSL https://raw.githubusercontent.com/cloud-in-a-bottle/cloud-in-a-bottle/main/scripts/provision.sh \ | sudo bash -s -- --domain mycooldomain.com --acme-email you@example.com ``` The script: 1. creates an unprivileged `host` user and copies root's `authorized_keys` to it, 2. installs system packages, rootless Podman, and pixi, 3. clones Cloud in a Bottle to `/home/host/openhost` (openhost is the old name of this project), 4. writes config to `/home/host/.openhost/local_compute_space/`, detecting the machine's public IP for the DNS records CoreDNS will serve, 5. registers a Let's Encrypt account key (that's what `--acme-email` is for), 6. installs and starts the `openhost` systemd service. ## 4. Claim the instance A publicly reachable instance is token-gated - otherwise the first stranger to find it could claim it. The installer prints a claim URL at the end of its output: ``` Claim URL: https://mycooldomain.com/setup?claim= ``` Open it, create your owner account, and you're good to go! If the certificate is still being issued, give it a minute and reload. ## Debug / Manage / Upgrade ```bash sudo systemctl status openhost sudo journalctl -u openhost -f ``` The service runs as the unprivileged `host` user, with code at `/home/host/openhost` and config and data under `/home/host/.openhost/local_compute_space/`. Upgrades are a button on the dashboard's settings page. If you want to poke around further, see [Debugging](../operation/debugging.md) and [The bottle CLI](../operation/cli.md). # Deploying on a dedicated home server Use this path when you have a machine at home that can just run Cloud in a Bottle: a spare desktop, a raspberry pi, an old laptop. Cloud in a Bottle installs directly on the host. It runs various system services, sets system-level configuration, and expects to be able to manage the system ongoing. If you don't want to give it the whole machine, [install into a VM instead](./shared_homeserver.md). If the machine is a VPS or cloud server rather than something on your home network, [deploy on a cloud instance](./cloud_instance.md) instead. ## Prerequisites - Ubuntu 24.04 on the target machine, freshly installed. - SSH access as `root`, or as a user with sudo. - A root filesystem supporting idmapped mounts (ext4, xfs, or btrfs). Install fails early with a clear error if it does not. --- ## Part 1: core instance setup ### 1. Install SSH to the machine as root and run: ```bash curl -fsSL https://raw.githubusercontent.com/cloud-in-a-bottle/cloud-in-a-bottle/main/scripts/provision.sh \ | sudo bash -s -- --domain lvh.me:8080 --local-http-only --open-claim ``` The script: 1. creates an unprivileged `host` user and copies root's `authorized_keys` to it, 2. installs system packages, rootless Podman, and pixi, 3. clones Cloud in a Bottle to `/home/host/openhost` (openhost is the old name of this project), 4. writes config to `/home/host/.openhost/local_compute_space/`, 5. installs and starts the `openhost` systemd service. ### 2. Reach the dashboard Port 8080 is bound to loopback, so tunnel to it over SSH: ```bash ssh -L 8080:localhost:8080 host@ ``` Then open `http://lvh.me:8080` and setup your instance! `lvh.me` is a public DNS name that simply resolves to `127.0.0.1`. Similar to `localhost`, but subdomains (`mycoolapp.lvh.me`) also resolve properly. You'll need to have that SSH tunnel active to access your instance until you take it public. ## Debug / Manage / Upgrade ```bash sudo systemctl status openhost sudo journalctl -u openhost -f ``` The service runs as the unprivileged `host` user, with code at `/home/host/openhost` and config and data under `/home/host/.openhost/local_compute_space/`. Upgrades are a button on the dashboard's settings page. If you want to poke around further, see [Debugging](../operation/debugging.md) and [The bottle CLI](../operation/cli.md). ## Part 2: taking it public Follow [Exposing a home server](./home_network.md) for a typical home ISP connection, or [Exposing a server with a static IP](./static_ip.md) if you have a static IP. # Deploying on a shared home machine Use this path when you want to run Cloud in a Bottle on a machine at home that you use for other things too: your desktop, a NAS, etc. Cloud in a Bottle wants to install directly on the host. It runs various system services, sets system-level configuration, and expects to be able to manage the system ongoing. So instead of installing on the host, you give it its own Ubuntu VM to live inside. Everything it does stays inside the VM's disk image, and it can't access your host system. If you're happy to dedicate the whole machine to it, [see this guide instead](./dedicated_homeserver.md); if it's a VPS or cloud server, see [Deploying on a cloud instance](./cloud_instance.md). This page is in two parts. Part 1 gets a working instance running inside a VM from our pre-built image. [Part 2](#part-2-taking-it-public) covers the networking and config if you want to put it on the public internet. ## Part 1: download and run the VM image Requirements: - an x86-64 processor (ie not an ARM processor like a Mac M-series). We plan to build ARM images in the future. - support for hardware virtualization. Most CPUs support this as long as you're running on bare metal, ie not already in a VM (VPS, EC2 instance, etc). It'll work without this but would be very slow. - a virtual machine host, like QEMU, VirtualBox, VMWare, etc. If you don't already have a preference, we suggest QEMU. - on ubuntu: `apt install qemu-system-x86 qemu-utils` The release image is a self-contained Ubuntu 24.04 appliance with Cloud in a Bottle already setup. Two formats are published per release: | File | Use with | | -------- | --------------------------------------- | | `.qcow2` | QEMU / KVM / libvirt (`virt-manager`) | | `.ova` | VirtualBox (and most other hypervisors) | Grab the latest version from the [releases page](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle/releases). ### Boot it Give the VM at least 1 vCPU, 2 GB RAM, and a disk of the size you want your instance to have (min 20GB). The root filesystem grows to fill it on first boot, so a 60 GB disk yields ~58 GB of usable space. - **VirtualBox:** *File → Import Appliance…*, select the `.ova`, adjust CPU/RAM/disk, and start it. - **QEMU / libvirt:** import the `.qcow2` as the VM's disk (e.g. `virt-manager`'s "Import existing disk image"), or boot it directly: QEMU instructions: ```bash qemu-system-x86_64 -enable-kvm -machine q35 -cpu host -smp 2 -m 4096 \ -drive file=openhost--amd64.qcow2,format=qcow2,if=virtio \ -netdev user,id=n0,hostfwd=tcp::8080-:8080,hostfwd=tcp::2222-:22 \ -device virtio-net-pci,netdev=n0 \ -nographic ``` The `hostfwd` options make the VM reachable. QEMU's default networking puts the guest on an isolated NAT with no address you can browse to, so instead we forward the guest's `:8080` and `:22` to `:8080` and `:2222` on the machine running QEMU. First boot runs `openhost-prepare.service` before the dashboard comes up; give it a minute. The local VM console logs in as user `host` with password `openhost` (change it with `passwd`). To get SSH access, log in on the console and append your public key as that `host` user: ```bash mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "ssh-ed25519 AAAAC3Nz... you@yourmachine" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys ``` Use the contents of `~/.ssh/id_ed25519.pub` from the machine you'll connect from, not the placeholder above. If you don't have one yet, run `ssh-keygen -t ed25519` there first. ### Access it Point a browser at `http://:8080/`, or `http://localhost:8080/` if you booted with the `hostfwd` line above. Create your owner account and you're good to go! ### Reaching your apps Cloud in a Bottle routes to apps by subdomain: an app named `foo` lives at `foo.`. The appliance has no real domain, so it uses [`lvh.me`](https://lvh.me), a public convenience domain where both `lvh.me` and `*.lvh.me` resolve to `127.0.0.1`. `lvh.me` resolves to the `127.0.0.1` of whatever machine is doing the browsing, so how you reach it depends on where your browser is. **Browsing on the machine running QEMU:** the `hostfwd` above is all you need, no SSH tunnel. The dashboard is at `http://lvh.me:8080/` and an app named `foo` at `http://foo.lvh.me:8080/`. **Browsing from another machine** (the VM lives on a NAS, you're on a laptop): forward the appliance's `:8080` to a local port over SSH. (`ssh -L 8088:localhost:8080 @`) That goes through your normal account on the machine running QEMU, so it needs no key on the appliance itself. If instead the VM has its own reachable address (bridged networking, or VirtualBox), you can tunnel straight to it with `ssh -L 8088:localhost:8080 host@` once you've added your key. Either way, browse to the dashboard at `http://lvh.me:8088/` and an app named `foo` at `http://foo.lvh.me:8088/`. You can pick whatever local port you like. ## Part 2: taking it public Follow [Exposing a home server](./home_network.md) for a typical home ISP connection, or [Exposing a server with a static IP](./static_ip.md) if you have a static IP. # Exposing a server with a static IP The point of a Bottle instance is that it is your own piece of the cloud: your apps sit on the public internet at ordinary URLs, and anyone (you, your friends/family, anyone you share something with) can access them from an ordinary browser on any device (with appropriate authentication, of course). Therefore, the instance needs to be publicly exposed on the internet and have its own domain name (mycoolspace.com). Using a static IP is the simpler path, and it is the case on essentially any VPS or cloud server. You need: - a static public IPv4 for the machine (the examples use `203.0.113.10`), - inbound `80/tcp`, `443/tcp`, and `53/tcp+udp` reachable from the internet. Port 53 is required, because the instance runs its own DNS server. If the machine is at home you probably have neither; see [Exposing a home server](./home_network.md) instead. This page converts an instance that is *already running* in HTTP-only mode. If you are starting fresh, [Deploying on a cloud instance](./cloud_instance.md) the a more direct path. ## Delegate DNS to the machine Cloud in a Bottle runs an authoritative DNS server for your zone. It serves the wildcard `*.mycooldomain.com`, so every app gets a subdomain without you touching DNS again, and it answers the ACME DNS-01 challenge used to issue the wildcard TLS certificate. So you delegate the whole zone to the machine rather than pointing an `A` record at it. For zone `mycooldomain.com` on a server at `203.0.113.10`, create two records at your DNS provider: | Type | Name | Value | |------|------------------------|------------------------| | `A` | `ns1.mycooldomain.com` | `203.0.113.10` | | `NS` | `mycooldomain.com` | `ns1.mycooldomain.com` | The `A` record is glue. An `NS` record can only name a host, not an IP, so something has to resolve `ns1` first. Check the delegation before continuing. It can take a while to propagate. ```bash dig +short NS mycooldomain.com # -> ns1.mycooldomain.com. ``` The instance now answers for everything at or below `mycooldomain.com`. You never create per-app records. ## Switch to TLS Once the DNS records above are live, add the public domain to the running instance from the dashboard. The instance acquires a certificate for it over DNS-01 and serves it alongside the domain you installed with. This takes two steps, because the HTTP-only install left the pieces that do the work switched off. Adding the domain first and fixing the config second is the order that works; doing it the other way round leaves the instance briefly refusing to start. ### 1. Add the domain In the dashboard, open **Settings → Domains**, enter `mycooldomain.com`, leave the type as **Public (HTTPS)**, and click **Add domain**. The domain is recorded immediately, and the instance kicks off certificate acquisition in the background. That attempt will fail, and the domain will show an error in the table: acquisition answers a DNS-01 challenge out of CoreDNS, and CoreDNS is not running yet in HTTP-only mode. Leave it; the next step re-drives it. ### 2. Turn on TLS and restart The HTTP-only install skipped the ACME account key, so generate one: ```bash KEY=/home/host/openhost/ansible/secrets/certbot_private_key.json sudo -u host bash -c "cd /home/host/openhost && /home/host/.pixi/bin/pixi run python3 scripts/generate_acme_key.py $KEY" sudo chmod 600 "$KEY" ``` Then edit `/home/host/.openhost/local_compute_space/config.toml`. Under `[openhost]`, flip three settings on, add the three ACME settings, and make sure `public_ip` is your server's real public IPv4, since CoreDNS answers every app subdomain with it: ```toml acquire_tls_cert_if_missing = true coredns_enabled = true start_caddy = true public_ip = "203.0.113.10" acme_account_key_path = "/home/host/openhost/ansible/secrets/certbot_private_key.json" acme_email = "admin@mycooldomain.com" acme_directory_url = "https://acme-v02.api.letsencrypt.org/directory" ``` `public_ip` is already in the file. A normal install detected it correctly at install time, but a downloaded VM image ships with a placeholder from the build machine, so set it to this server's address. Restart: ```bash sudo systemctl restart openhost ``` Make the edit before restarting. A TLS domain in the database with `start_caddy = false` is a configuration the router rejects outright, so a restart in between fails with *"A TLS domain is configured but start_caddy is False."* On this boot the instance starts CoreDNS authoritative for `mycooldomain.com`, starts Caddy on 443, notices the domain's certificate is missing, acquires it, and reloads Caddy to serve it. Watch it happen: ```bash sudo journalctl -u openhost -f ``` The domain flips to active in the settings table once the certificate lands. The dashboard is then reachable at `https://mycooldomain.com/`, apps at `https://.mycooldomain.com/`, and you can drop the SSH tunnel: ```bash curl https://mycooldomain.com/health # -> {"status":"ok"} ``` ### 3. Make the public domain primary Your original local domain, such as `lvh.me:8080`, is still the primary. In **Settings → Domains**, choose **Make primary** beside `mycooldomain.com`. The instance and running apps restart, and your browser moves to the new domain; you may need to sign in again. Once it works, you can remove the old local domain. # Exposing a home server The point of a Bottle instance is that it is your own piece of the cloud: your apps sit on the public internet at ordinary URLs, and anyone (you, your friends/family, anyone you share something with) can access them from an ordinary browser on any device (with appropriate authentication, of course). Therefore, the instance needs to be publicly exposed on the internet and have its own domain name (mycoolspace.com). This is easy if you have a static IP address, but trickier at home where you typically don't. ## HTTP(s) tunnel services Using a HTTP(s) tunnel like the (free) [Cloudflare Tunnels](https://developers.cloudflare.com/tunnel/) is the easiest way to get your instance online, currently. HTTP tunnels are cheap to provide, because instead of having a dedicated IPv4 per client, they share a single IP among many clients. They do this via receiving HTTP(s) traffic on a shared IP, routing it by the hostname specified in the HTTP packet, and reverse-tunneling it into a potentially-firewalled machine. This only works for HTTP-based traffic, because it needs to know how to differentiate between traffic for different users on the shared IP, which is impossible to do in general for arbitrary non-HTTP protocols that don't carry hostnames. This isn't ideal, because there are some non-HTTP protocols that we would like Cloud in a Bottle should be able to receive inbound traffic on, eg SMTP (email). We're working on a better alternative (see below). That said, most apps only use HTTP and will work fine. Just remember that any apps specifying non-standard `[[ports]]` in the manifest won't work properly. ### Cloudflare Tunnels setup These steps assume you already have a running Cloud in a Bottle instance in local-only mode, and work with Cloudflare's free plan. **Prerequisites** - An apex domain (`mycooldomain.com`, not `bottle.mycooldomain.com`) whose DNS is managed by Cloudflare (nameservers delegated to Cloudflare). - "Always Use HTTPS" enabled on the zone (Cloudflare dashboard: "Select domain -> SSL/TLS → Edge Certificates"). This setting redirects `http://` requests to `https://` at the edge. - Claim your instance before the tunnel goes live. The VM image and any `--local-http-only` bring-up assume the instance is private behind NAT, so claiming may be open (no token). Make sure you finish the instance setup flow before making it public. **1. Point Cloud in a Bottle at your domain.** First add the domain in the dashboard under **Settings → Domains**: enter `example.com` and choose **Public (HTTPS)**. Cloudflare terminates TLS at its edge, but Cloud in a Bottle still needs to know the public scheme is `https` so that the URLs it generates (and passes to apps) are correct. The local hop from `cloudflared` to the router stays plain HTTP either way. The domain will then report a certificate error, `ACME account key path must be set in config to acquire TLS cert`, which is expected here: Cloudflare supplies the certificate, so Bottle never needs one. **2. Install `cloudflared`** in the VM: ```bash sudo mkdir -p --mode=0755 /usr/share/keyrings curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \ | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt-get update && sudo apt-get install -y cloudflared ``` **3. Create the tunnel** ```bash cloudflared tunnel login # prints a URL: open it in a browser (on any machine) and authorize the zone. cloudflared tunnel create bottle # `create` writes a credentials file to `~/.cloudflared/.json` and prints the tunnel's UUID. ``` **4. Route DNS to the tunnel** ```bash cloudflared tunnel route dns bottle example.com cloudflared tunnel route dns bottle '*.example.com' ``` You can also add them by hand in the Cloudflare dashboard under **DNS → Records** if you prefer. **5. Write the tunnel config** with a wildcard ingress rule so all app subdomains flow to the one router. Put both the config and credentials under `/etc/cloudflared/` so the system service can read them: ```bash sudo mkdir -p /etc/cloudflared sudo cp ~/.cloudflared/.json /etc/cloudflared/ ``` `/etc/cloudflared/config.yml`: ```yaml tunnel: credentials-file: /etc/cloudflared/.json ingress: - hostname: example.com service: http://127.0.0.1:8080 - hostname: "*.example.com" service: http://127.0.0.1:8080 - service: http_status:404 ``` Check it parses: `cloudflared tunnel ingress validate` **6. Run it as a service:** ```bash sudo cloudflared service install sudo systemctl enable --now cloudflared ``` **7. Access your instance over the tunnel** Go to your configured domain name in a browser from any machine, and you should be able to access your instance! If it doesn't come up, try these: ```bash systemctl status cloudflared # is the connector running? cloudflared tunnel info bottle # does Cloudflare see active connections? curl -I -H "Host: example.com" http://127.0.0.1:8080/ # is Bottle itself answering? ``` A `502` at the edge while that last command returns a `302` means the tunnel is healthy and the app behind it isn't. `Connection refused` instead means Bottle isn't running, or isn't listening on `:8080`. For anything else, `journalctl -u cloudflared -f` shows the connector's own view. ## Tailscale: HTTP or HTTPS These steps assume you have already provisioned an instance and can access its dashboard. This approach does not make the instance publicly accessible. It makes the instance available only to devices on your tailnet, wherever those devices are connected to the internet. ### 1. Install and connect Tailscale Run the commands in this guide from the terminal in the Cloud in a Bottle dashboard or over SSH to the instance. First, install Tailscale on the instance: ```bash sudo snap install tailscale --classic ``` Then log in to Tailscale: ```bash sudo tailscale up ``` For a long-running server, you may also want to disable key expiry for this machine in the Tailscale admin console so it does not require periodic reauthentication. ### 2. Point a domain to the Tailscale IP Choose a domain you control the DNS for. We'll use `bottle.example.com`. Run `tailscale ip -4` on the instance, then create these records at your DNS provider using the address it prints: | Type | Name | Value | |------|------------------------|----------------------| | `A` | `bottle.example.com` | `` | | `A` | `*.bottle.example.com` | `` | The wildcard record gives every app its own working subdomain. ### 3. Add the domain Using your existing connection to the dashboard, open **Settings → Domains** and enter `bottle.example.com`. Choose **HTTP** to continue serving plain HTTP, or **HTTPS** to serve the domain with a certificate. #### HTTP Choose **HTTP** and click **Add domain**. #### HTTPS Choose **HTTPS** and click **Add domain**. The initial automatic certificate attempt may fail because this setup keeps DNS at your provider rather than delegating it to the instance. Use [acme.sh](https://github.com/acmesh-official/acme.sh) with your DNS provider's [DNS API plugin](https://github.com/acmesh-official/acme.sh/wiki/dnsapi) to complete the DNS-01 challenge and issue a wildcard certificate: ```bash DOMAIN=bottle.example.com CERT_DIR=/home/host/.openhost/local_compute_space/persistent_data/openhost/certs sudo install -d -o host -g host "$CERT_DIR" curl -fsSL https://get.acme.sh | sh -s email=you@example.com # Export the credentials required by your DNS provider's plugin first. ~/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_yourprovider \ -d "$DOMAIN" -d "*.$DOMAIN" ~/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \ --fullchain-file "$CERT_DIR/$DOMAIN.pem" \ --key-file "$CERT_DIR/$DOMAIN.key" \ --reloadcmd "sudo systemctl restart openhost" sudo chown host:host "$CERT_DIR/$DOMAIN.pem" "$CERT_DIR/$DOMAIN.key" sudo chmod 0644 "$CERT_DIR/$DOMAIN.pem" sudo chmod 0600 "$CERT_DIR/$DOMAIN.key" ``` The dashboard is then reachable at `https://bottle.example.com`, apps at `https://.bottle.example.com`, and acme.sh renews and installs the certificate through the same DNS API. Once the new domain works, choose **Make primary** beside it in **Settings → Domains**. The instance and running apps restart, and your browser moves to the new domain; you may need to sign in again. You can then remove the old local domain. ## IPv4 tunnel service IPv4 addresses aren't free, but also aren't that expensive (see spot lease prices eg [here](https://www.ipxo.com/lease-ips/)). It ought to be possible to operate a service that attaches an IP address to a server and forwards any traffic arriving at that IP to your firewalled Bottle instance over a reverse proxy connection, thus avoiding any need for an IP from your ISP and fiddling with router settings. Unfortunately, we can't find any service that actually does this for an individual for reasonable price - so we're building this ourselves, to make it easier for users to get their self-hosted instances online. This feature should be available soon! ## Using the dynamic IP from your ISP This isn't an officially supported path, for a few reasons: - not all ISPs give you you own IP - CGNAT (putting many users on a single shared IP) is becoming more common - residential IPs are typically not static, which means you need some way to update your external DNS records automatically - often they will restrict the inbound ports you can receive traffic from. eg receiving port 25 (SMTP, for email) is typically blocked - to allow traffic in, you have to tell your router to forward traffic from external ports to your home server Overall, the benefits seem low vs using eg Cloudflare Tunnels, which is much easier to setup. But it could be done if you really wanted to, probably. # Using your instance Everything day to day happens in the dashboard at `https:///`. This page is the tour: what is there, what came preinstalled, and how to put your own things on it. ## The dashboard | Page | What it is | |---|---| | **Dashboard** | Every app, its status, and a link into each one | | **Catalog** and **Deploy app** | The two ways to install something | | **Settings** | Your account, API tokens, domains, archive storage, updates | | **System info** | Memory, disk and per-app resource use, plus a full diagnostics dump | | **Terminal** | A shell on the machine, in the browser | | **Docs** | This manual, served from the version you are running | An app's "detail" page has its logs, its resource use, the paths it declared, and the buttons to reload, stop or remove it. ## Default Apps A new instance installs a handful of apps at setup: | App | What it is for | |---|---| | **Catalog** | Browse apps and install them in a click | | **Backup** | Encrypted backups to external storage. See [Backups and restore](./backups.md) | | **Files** | A file browser over your instance's data | | **Secrets** | Key-value secret storage other apps can request through the [secrets service](../creating_an_app/cross_app_services.md) | | **OAuth provider** | Holds your Google and GitHub tokens so apps can act on your behalf. See the [oauth service spec](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle/blob/main/services/oauth/README.md) | | **Community chat** | A Matrix server and client, pre-configured to join the Cloud in a Bottle chat server | Each one is an ordinary app on its own subdomain, and you can remove any of them. ## Installing apps Two routes, both ending in the same place. **From the catalog.** Open the catalog app, find something, click Install. It hands you to the Deploy app page with the repository and name already filled in. The catalog is a curated feed of known-good apps; open a PR in the [app-manifest](https://github.com/cloud-in-a-bottle/app-manifest) repo to request adding a new app. **From a git repository.** Deploy app → paste the URL of any repo with a `cloudinabottle.toml` at its root. Private GitHub repos prompt for authorization the first time. The router clones the repo, reads the manifest, builds the container image, and starts the app at `https://./`. See [Creating an App](../creating_an_app/overview.md) for more on creating your own apps. ### Before you click install The install page shows what the app asked for in its manifest: how much memory, which data it wants, whether it wants other apps' data, extra Linux capabilities, host devices, host networking, and which cross-app services it consumes. You should review this carefully. By default, apps have minimal permissions to do unsafe things in your space. But if given elevated access, the potential harm can become much greater. [Security](../how_it_works/security.md) explains what each request actually grants. Updates get the same treatment: when an app's manifest changes, the update page shows the changed settings and any new service permissions, and nothing is granted until you approve. ### Updating apps Apps do not currently auto-update themselves. There's a button in the app details page to manually fetch + install any updates. ## Who can use your apps By default, only you. Every route of every app requires your owner session, and the router enforces that before the request reaches the app. An app can open specific routes to everyone by listing them in `public_paths` in its manifest, which is how a blog, a webhook receiver, or a public share link works. That is all-or-nothing: a public path is public to the entire internet, and anything behind it is the app's job to protect. There is no way yet to give a named person their own login to your instance or to specific apps; this is on the roadmap. ## Settings worth knowing about - **Domains.** Add or remove the names the instance answers on. A public domain gets a wildcard certificate automatically, so every app is reachable over HTTPS without further work. See [Routing](../how_it_works/routing.md). - **Archive storage.** Point the bulk-content tier at an S3 bucket instead of local disk. See [Data](../how_it_works/data.md#the-archive-tier). - **API tokens.** Create and revoke the tokens the [CLI](./cli.md) and any scripts use. They are owner-equivalent, so give them expiries. - **Owner account.** Change the username apps see, and your password. - **Updates.** Check for and apply new Cloud in a Bottle versions. It pulls the code, runs any host migrations, and restarts, streaming progress while it goes. ## Backups See [backups](./backups.md). # Backups and restore Every instance comes with a backup app, installed at setup and reachable at `https://backup./`. It is [restic](https://restic.net/) underneath, so backups are encrypted, incremental and deduplicated, and they go to storage you choose. Nothing is backed up until you configure it. ## Set it up Open the backup app and fill in: - **Repository URL**: where snapshots go. Restic speaks S3, Backblaze B2, Google Cloud Storage, Azure Blob, Swift, SFTP, rclone remotes, a REST server, or a local path. - **Repository password**: the encryption key. Snapshots are useless without it, and nobody can recover it for you. Store it somewhere separate from the instance. - **Backend credentials**: whatever your storage needs, as environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and so on). - **Interval**: seconds between automatic backups, minimum 60. Leave it at 0 and nothing runs on its own. - **Retention**: `keep-last`, `keep-hourly`, `keep-daily`, `keep-weekly`, `keep-monthly`, `keep-yearly`. The rules add together, so `keep-last=5, keep-daily=7` keeps the five newest snapshots plus one per day for a week. All zeros means nothing is ever deleted. Test the connection, then run a backup by hand to confirm it works. The scheduler survives restarts and does not restart its countdown. ## What is in a backup | Included | Not included | |---|---| | `/data/app_data`, every app's permanent data, including SQLite databases | The archive tier, which is expected to be durable where it lives | | `/data/app_temp_data`, scratch and build artifacts | The backup app's own directory, so the repository can't back up itself | | | Router state: the database, TLS certificates, identity keys | The last row matters. The router's own data lives outside every app's mounts, so no app can see it, including this one. A restored instance gets your apps and their data back; it does not get the instance's own configuration back. See [what to keep yourself](#what-the-app-cannot-reach) below. Archive data is excluded because on the S3 backend the bytes already live in your bucket. On the default local backend they do not live anywhere else, so an instance using the archive tier locally has no off-machine copy of it at all. If you keep anything you care about in the archive tier, move that zone to S3 (see [Data](../how_it_works/data.md#the-archive-tier)). ## Restore Restoring happens from the snapshot browser in the same app. Pick a snapshot, restore everything or a single data root, and the files are written back in place, overwriting what is there. The app's own directory and the archive tier are left alone. Reload the affected apps from the dashboard afterwards. A running container holds its own view of files it has open, and databases in particular will not notice that their files changed underneath them. ## Moving to another machine The backup app has a migration tab that pushes apps and their data straight to another instance: it sends the app list, the target stops those apps and clears their directories, the data streams across, and the target redeploys. You need an API token for the target instance. For a machine that is already gone, install a fresh instance, install the backup app, point it at the same repository with the same password, and restore. ## What the app cannot reach Neither backup nor migration carries the router's own state, so keep a copy of it yourself if a rebuild would hurt: ```bash bottle instance rsync -a host@:/home/host/.openhost/local_compute_space/persistent_data/openhost/ ./instance-state/ ``` That directory holds `router.db` (your apps, domains, API tokens, owner account), the TLS certificates, and the identity keys. Certificates are re-acquired automatically on a new machine, so the database is the part worth having. # The bottle CLI `bottle` does what the dashboard does, without a browser. Nothing needs it; it exists for people who prefer a terminal, for scripting, and for handing an instance to an AI agent. ## Install and log in ```bash uv tool install "cloud-in-a-bottle-cli @ git+https://github.com/cloud-in-a-bottle/cloud-in-a-bottle.git#subdirectory=compute_space_cli" ``` From a local clone instead, which then tracks whatever you pull: ```bash cd /compute_space_cli && uv tool install --editable . ``` Then: ```bash bottle instance login ``` It asks for your instance URL, sends you to the dashboard to create an API token, and takes the token back. Config lands in `~/.cloud_in_a_bottle_cli/compute_space_cli.toml`. ## Instances One CLI can hold several instances. Commands use the default unless you say otherwise: ```bash bottle --instance work app list ``` | Command | Does | |---|---| | `bottle instance list` | Configured instances, with aliases and the default marked | | `bottle instance add [--alias a] [--set-default]` | Add one non-interactively | | `bottle instance set-default ` | Change the default | | `bottle instance alias ` | Give one a short name | | `bottle instance remove ` | Forget it locally | | `bottle instance token` | Print the stored API token | ## Apps | Command | Does | |---|---| | `bottle app list` | Every app and its status | | `bottle app deploy [--name n] [--wait]` | Install from a repo | | `bottle app status ` | One app's state | | `bottle app logs [--follow]` | Build log, then container log | | `bottle app reload [--update] [--wait]` | Rebuild and restart; `--update` pulls first | | `bottle app stop ` | Stop it | | `bottle app remove [--keep-data]` | Remove it, and its data unless told otherwise | | `bottle app rename ` | Rename it, and its subdomain with it | | `bottle app ssh [--shell bash] [cmd]` | A shell inside the container | | `bottle app diagnostics ` | JSON bundle of that app's state | Deploying from a private GitHub repo needs a one-time browser authorization; the CLI prints the link. After that it is non-interactive. ## The instance itself | Command | Does | |---|---| | `bottle status` | Is it reachable? | | `bottle version` | Which branch and commit are running | | `bottle logs [--follow]` | Router logs | | `bottle diagnostics` | JSON bundle of instance state | | `bottle tokens list \| create \| delete` | API tokens (`create --name ci --expiry-hours 72`) | | `bottle curl ` | `curl` with your bearer token attached | | `bottle instance ssh [args]` | SSH to the machine as `host` | | `bottle instance rsync ` | `rsync` over that same SSH | `bottle instance configure-ssh-key ` stores the key the last two use. `bottle --help` is authoritative for anything not listed here. # Debugging When something is wrong and the dashboard isn't telling you enough. ## Where to start | Symptom | Look at | |---|---| | Dashboard unreachable | `sudo systemctl status openhost`, then `sudo journalctl -u openhost -n 200` | | An app won't build | `bottle app logs ` (the build log is the first half of it) | | An app starts and dies, repeatedly | Memory. The default limit is 128 MB; `bottle app logs ` shows the exit, and the router logs an OOM warning | | An app builds but 502s | The app's own container log, same command; check it binds the port from its manifest | | Apps stopped on their own | Free disk. The [storage guard](../how_it_works/data.md#storage-guard) stops apps below 500 MB free | | A domain has no certificate | `journalctl -u openhost -f` during acquisition; DNS-01 needs the zone delegated (see [Routing](../how_it_works/routing.md#tls-certificates)) | ## The service Cloud in a Bottle runs as a single systemd unit, `openhost`, as the unprivileged `host` user. ```bash sudo systemctl status openhost sudo systemctl restart openhost sudo journalctl -u openhost -f ``` | What | Where | |---|---| | Service | `openhost` (systemd) | | Code | `/home/host/openhost` | | Config | `/home/host/.openhost/local_compute_space/config.toml` | | Data | `/home/host/.openhost/local_compute_space/`, see [Overview](../how_it_works/overview.md#on-the-machine) | Config changes take effect on restart. Caddy and CoreDNS are children of this unit, so restarting it restarts them too. ## Logs `bottle logs` and `journalctl -u openhost` cover the router; `bottle app logs ` covers one app. Which log holds what, and where the files live, is in [Logs](../how_it_works/logs.md). ## Diagnostics ```bash bottle diagnostics # instance-wide state, as JSON bottle app diagnostics # one app ``` Both are meant to be pasted into an issue or handed to an agent. ## Getting a shell The dashboard has one on the machine at `/terminal/`, which is the quickest way in when SSH isn't set up. Otherwise `bottle instance ssh`, or `ssh host@`. For a shell inside an app's container, `bottle app ssh `. ## Locked out The owner account is a bcrypt hash in the router's database, and there is no reset link. If you still have SSH access, set a new password directly: ```bash cd /home/host/openhost sudo -u host /home/host/.pixi/bin/pixi run python - <<'EOF' import bcrypt, sqlite3 db = sqlite3.connect("/home/host/.openhost/local_compute_space/persistent_data/openhost/router.db") db.execute("UPDATE users SET password_hash = ?", (bcrypt.hashpw(b"new-password-here", bcrypt.gensalt()).decode(),)) db.commit() EOF ``` No restart is needed; the next login reads the new hash. Existing sessions and API tokens keep working, so revoke anything you don't recognise afterwards. ## Updating by hand The update button on the dashboard's settings page is the normal path: it checks the configured git remote, pulls, runs any host-level migrations, syncs dependencies, and restarts. A progress page streams it, and a stand-in server holds ports 80 and 443 during the restart. If the dashboard is unreachable, the same thing over SSH: ```bash sudo openhost_system_agent update apply ``` # AI agents A coding agent can do the whole loop on an instance: package a project as a Bottle app, deploy it, read the build log, fix what broke, and reload. It does that through the [`bottle` CLI](./cli.md), which injects auth so the agent never handles a token. ## The agent skill Install the skill to give your agent the context it needs: ```bash npx skills add cloud-in-a-bottle/cloud-in-a-bottle --skill cloud-in-a-bottle-context ``` The skill is just the markdown file [`skills/cloud-in-a-bottle-context/SKILL.md`](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle/blob/main/skills/cloud-in-a-bottle-context/SKILL.md) in the repo, if you'd prefer to use it directly. The skill assumes `bottle` is [installed and logged in](./cli.md#install-and-log-in). Logging in is interactive, so do it yourself before handing over. If you have more than one instance, tell the agent which: every command takes `--instance `. ## Feeding it the manual Any page is served as its own Markdown source by adding `.md` to its URL, and the whole manual (every page, concatenated) is served at [`/docs/all.md`](/docs/all.md): ```bash curl https://mycooldomain.com/docs/how_it_works/routing.md curl https://mycooldomain.com/docs/all.md ``` The copy icons put the same text on your clipboard: the one beside each page's heading copies that page, and the one beside **Cloud in a Bottle Manual** in the sidebar copies the whole thing. An agent reading the manual off your own instance gets the version you are actually running, not whatever is on the web. # Overview An instance is one machine running one service: the router (`openhost.service`). The router faces the network, and everything else on the machine is either a process it supervises or an app container it started. ## The pieces - **Router**: serves the dashboard and the API, proxies every request to the right app, builds and runs app containers, and manages domains and certificates. Listens on `:8080`. - **Caddy**: terminates TLS on `:443` and forwards to the router; redirects `:80` to HTTPS. Skipped on HTTP-only installs. - **CoreDNS**: authoritative DNS for your zone, so certificate challenges can be answered and misc other DNS entries created (eg for SMTP/email). Runs only when the zone is delegated to the machine. - **App containers**: one rootless Podman container per app, each in its own user namespace, with only the data directories it asked for mounted in. Nothing reaches an app except through the router. - **System agent**: a small root helper for the things the unprivileged `host` user cannot do itself, such as host configuration and software updates. Caddy and CoreDNS are child processes of the router, so their output appears in the router's logs and they stop when it stops. ## A request, end to end A browser opening `https://notes.mycooldomain.com/`: 1. **DNS**: the wildcard record `*.mycooldomain.com` resolves to the machine's public IP. 2. **TLS**: Caddy terminates the connection on `:443` with the wildcard certificate and forwards to the router over loopback. 3. **Match**: the router reads the `Host` header, finds which of its domains owns it, and takes `notes` as the app name. 4. **Auth**: unless the app declared that path public, the request must carry a valid owner session; otherwise the browser is sent to the login page. 5. **Proxy**: the request is passed to the app's container port, with identity headers set by the router. WebSockets are proxied the same way. A request to the domain itself, with no app subdomain, is the dashboard. ## Access By default every path of every app requires the owner to be logged in. The router enforces this before it proxies anything, so an app never sees an unauthenticated request unless it asked for one. There are three ways a request can authenticate: | Credential | Used by | How | |---|---|---| | Session cookie | Browsers | `session_token`, set at login. Opaque and stored in the router's database, so it can be revoked. Valid four weeks, and scoped to the domain, so one login covers the dashboard and every app on it. | | API token | The `bottle` CLI, scripts, agents | `Authorization: Bearer `. Created and revoked in the dashboard or with `bottle tokens`. | | App token | App containers | `BOTTLE_APP_TOKEN`, injected into each container and used to authenticate [cross-app service calls](../creating_an_app/cross_app_services.md). | Apps can open up specific routes by listing them in `public_paths` in their manifest. Those routes are proxied without authentication, and it is then the app's job to decide who may do what. To help, the router sets `X-OpenHost-Is-Owner: true` on requests that do carry a valid owner session, so an app can serve a public page and still show the owner an edit button. Any `X-OpenHost-*` header supplied by the client is stripped before the app sees it. ## On the machine The instance keeps everything under one data directory, `data_root_dir` in `config.toml`, which is `~/.openhost/local_compute_space/` for the user the service runs as (`/home/host/...` on a provisioned server). | Path | Contents | |------|----------| | `/home/host/openhost` | The checkout the service runs from | | `/config.toml` | Instance configuration | | `/persistent_data/` | App permanent data, plus `openhost/`: the router database, TLS certificates and keys, the generated Caddy and CoreDNS config | | `/temporary_data/` | App scratch space, build and container logs | | `/app_archive/` | The archive tier mount | Read on for [Routing](./routing.md), [Data](./data.md), and [Logs](./logs.md). # Routing Every app gets its own subdomain of one of the instance's domains: `notes.mycooldomain.com` is the app named `notes`. You never create a DNS record or issue a certificate per app; a wildcard covers all of them. ## Domains An instance answers on one or more domains, managed in **Settings → Domains**. One is the primary: the canonical domain used for outbound links and background tasks. Each domain is one of: - **Public (HTTPS)**: a real domain served over TLS. The usual case. - **Local HTTP**: a plain-HTTP domain such as `lvh.me:8080`, served by the router directly. Used before an instance is taken public. Host matching is longest-suffix, so overlapping domains resolve to the most specific one. ## DNS For a public domain, you delegate the whole zone to the machine rather than pointing an `A` record at it (see [Exposing a server with a static IP](../setup/static_ip.md)). The instance then runs CoreDNS as the authoritative nameserver for the zone and serves: - an `A` record for the domain itself, - a wildcard `A` record for `*.domain`, so every app subdomain resolves, - `TXT` records under `_acme-challenge.domain`, written only while a certificate is being issued. CoreDNS reloads automatically when the zone file changes. Delegating the zone is what makes both the wildcard and DNS-01 certificate validation work without you doing anything per app. If you would rather manage DNS yourself, you can: point `domain` and `*.domain` at the machine at your provider, disable CoreDNS, and supply certificates yourself; the [home server](../setup/home_network.md) page shows this with acme.sh. ## TLS certificates Each TLS domain gets one certificate covering both `domain` and `*.domain`, validated over ACME DNS-01: the instance writes the challenge `TXT` record into its own CoreDNS zone, the CA reads it, and the record is removed afterwards. There are two ways to reach a CA: - **Your own ACME account**: configure `acme_account_key_path`, `acme_email`, and `acme_directory_url` (Let's Encrypt by default). The instance talks to the CA directly. - **The Imbue certificate broker**: used by managed spaces. The instance generates its keypair and CSR locally and sends only the CSR; the private key never leaves the machine. Certificates are cached on disk and reused across restarts. A background task checks every 12 hours and renews anything expiring within 7 days, then reloads Caddy. A domain whose certificate has not landed yet is served with Caddy's self-signed certificate, so it is reachable, with a browser warning, while acquisition is in flight. ## The proxy path Caddy sits in front and does nothing but TLS: - **`:443`**: terminates TLS and reverse-proxies to the router on loopback `:8080`. - **`:80`**: permanently redirects to HTTPS. Caddy's config is generated by the router at startup; there is no Caddyfile to edit. An HTTP-only install runs with `start_caddy = false` and no Caddy at all; the router serves `:8080` directly. Configuring a TLS domain without Caddy is refused at startup, since nothing would terminate TLS. The router then matches the `Host` header to a domain, extracts the app name from the subdomain, and proxies to that app's container port. Requests for the bare domain are handled by the router itself: the dashboard, the API, and the docs you are reading. ## What the app sees The router forwards HTTP and WebSocket traffic alike, and sets a few headers on the way in: - `X-Forwarded-For`: the real client IP, taken from Caddy. - `X-OpenHost-Is-Owner: true` is set only when the request carries a valid owner session. Any `X-OpenHost-*` header from the client is stripped first; the router is the only thing allowed to set them. Apps that speak something other than HTTP can bind extra host ports with `[[ports]]` in the manifest; those bypass the router and are exposed directly on the machine. See the [manifest spec](../creating_an_app/manifest_spec.md). # Data Each app gets its own directories, mounted into its container under `/data/`. Apps see the same path layout no matter what they have access to (only the directories they were granted are actually mounted), so the structure never changes when permissions do. ## The three tiers | Tier | In the container | Backing | Backed up | For | |---|---|---|---|---| | Permanent | `/data/app_data/` | Local disk | Yes | SQLite databases, notes, config, small assets | | Temporary | `/data/app_temp_data/` | Local disk | Not guaranteed | Thumbnails, transcodes, build artifacts, anything recreatable | | Archive | `/data/app_archive/` | JuiceFS, local or S3 | See below | Bulk content: photos, video, attachments, model weights | Apps get permanent data by default and request the other two in their manifest (`app_temp_data`, `app_archive`). They should read the paths from `BOTTLE_APP_DATA_DIR`, `BOTTLE_APP_TEMP_DIR` and `BOTTLE_APP_ARCHIVE_DIR` rather than hardcoding them. See [Creating an App](../creating_an_app/overview.md#data-storage) for the app author's view. The split between permanent and archive matters more than it looks. Permanent data is local disk with real `fsync` and strict POSIX semantics, which is what an embedded database needs: SQLite, LMDB, RocksDB and friends belong there and nowhere else. The archive tier is a network-shaped filesystem: fine for whole files, wrong for a write-ahead log or for `fcntl` locks used for correctness. An app that stores bulk content normally uses both, keeping its index in permanent data and the bytes in the archive. An app can also request `access_all_app_data`, which mounts every app's directories read-write. This is for file browsers, backup tools and the like. ## The archive tier The archive is always a JuiceFS volume, so an app that asks for it installs anywhere. Only the object storage underneath differs: - **Local (default)**: objects live on the instance's own disk. Nothing to configure, but there is no copy anywhere else, and the bundled backup app skips the archive tier. - **S3**: objects live in a bucket you supply, configured in the dashboard. Elastic and durable, at the cost of tens to hundreds of milliseconds on an uncached first read. Backups skip it, since the bytes already live in the bucket. Switching from local to S3, or from one bucket to another, is done from the dashboard behind a confirmation. The objects are copied and verified, then the same volume is re-pointed at the new store; the metadata database is untouched, so every file, permission and owner is preserved. It fails open: if anything goes wrong before the switch commits, the volume keeps reading from the store it was already using. ## Where it lives on disk Everything sits under the instance's data directory (`data_root_dir` in `config.toml`, normally `~/.openhost/local_compute_space/`): | Path | Contents | |---|---| | `persistent_data/app_data//` | Permanent app data | | `persistent_data/app_archive_local_objects/` | JuiceFS objects, on the local backend only | | `persistent_data/openhost/` | Router database, TLS certificates and keys | | `temporary_data/app_temp_data//` | Temporary app data, plus that app's build and container logs | | `app_archive/` | The JuiceFS mount | The [bundled backup app](../operation/backups.md) copies the app data under `persistent_data/`, which is the point of the split. It does not copy `persistent_data/openhost/`: router state is never mounted into any container, so no app can see it, and reaching it means SSH or the terminal in the dashboard. ## Storage guard Running a disk to zero on a machine that hosts your own data is worse than stopping early, so the instance reserves headroom. When free space drops below `storage_min_free_mb` (500 MB by default) the storage guard stops running apps until space is freed. Change the threshold in `config.toml` (or set it to `0` to switch the guard off), then restart. # Security An instance is a machine on the public internet running code you did not write. This page is what the platform does about that, and where the line is. ## App sandbox Every app runs as a rootless Podman container under the unprivileged `host` user. Container-root maps to an unprivileged subuid on the host, not to real root, and bind mounts use idmapped mounts so files an app writes are owned by `host` on disk. By default a container gets: - **Its own data directories and nothing else.** `/data/app_data/` and, if requested, its temp and archive directories. It cannot see other apps' data, the router's database, TLS keys, or anything else on the machine. - **No capabilities beyond the Docker defaults.** The router drops all capabilities and re-adds the standard set, plus `no-new-privileges`. - **No devices beyond the OCI baseline** (`/dev/null`, `/dev/zero`, `/dev/random`, and friends). - **One port, on loopback.** The container's HTTP port is published to `127.0.0.1` on the host, so nothing reaches an app except through the router. - **A resolver pointed at the instance's own DNS view**, which is what lets an app reach a sibling app at its public URL and get normal routing and auth applied on the way in. Apps can request additional capabilities/permissions that significantly elevate the access they have to your instance. These are documented in [the manifest spec](../creating_an_app/manifest_spec.md); it is your choice if you are comfortable installing apps requesting elevated permissions. ## Who can reach an app Every route requires the owner's session unless the app's manifest lists it in `public_paths`. The router checks this before proxying, so an unauthenticated request never reaches an app that did not ask for one. Three credentials authenticate as you, all documented in [Overview](./overview.md#access): the browser session cookie, API tokens, and each app's own token for [cross-app calls](../creating_an_app/cross_app_services.md). App tokens identify the calling app to a provider; they are not owner credentials and cannot be used to reach the dashboard. Owner sessions are refused on cross-origin requests, so JavaScript running in one app cannot make owner-authenticated calls to the router or to another app on your behalf. The router is the sole authority for the `X-OpenHost-*` headers an app receives. Anything a client sends under those names is stripped before the app sees it, so an app can trust `X-OpenHost-Is-Owner` and a provider can trust the consumer name it is handed. ## Permissions between apps An app that consumes a [cross-app service](../creating_an_app/cross_app_services.md) does not get access to the provider's data by just installing. It declares the grants it wants, you approve them, and the router forwards only the granted set on each call. Enforcement is the provider's: the router carries the grant, the provider decides what it means. Provider-scoped grants let a provider run its own approval UI for anything data-dependent, such as which folder or which mailbox. ## Catalog apps We review apps before including them in our curated catalog manifest (https://github.com/cloud-in-a-bottle/app-manifest), but don't guarantee they are safe. Additionally, app authors can push changes to their apps after they are included in the catalog, and we don't re-review these changes. Currently we alleviate this by only including apps we have packaged, or from authors that we have a relationship with. We plan to revisit this, but ultimately it is the responsibility of the instance owner to determine if they trust an app with the capabilities/permissions the app is requesting. # Logs ## Router logs The router writes to two places at once. **A file**, at `/persistent_data/openhost/compute_space.log`: - INFO and above. - Truncated when the router starts, so it holds only the current run. - Rotates at 10 MB, keeping 5 rotated files alongside it. - Read it with `bottle logs`. **journald**, via systemd: - DEBUG and above. - Includes Caddy and CoreDNS output, since both are child processes of the router. - Survives restarts, so this is where to look when the router died or failed to start before file logging was up. - Read it with `journalctl -u openhost`, or `-f` to follow. `systemctl status openhost` shows the last few lines. The journal is capped at 500 MB (`SystemMaxUse` in `/etc/systemd/journald.conf.d/10-openhost.conf`) so it can't fill the disk. ## App logs Each app has two log files, both under `/temporary_data/app_temp_data//`: - `docker.log`: build output from `podman build`, plus container start and stop events. - `container.log`: the container's own stdout and stderr. `bottle app logs ` shows the build log followed by the live container log, which is usually what you want while a deploy is in flight. The dashboard shows the same thing. On each reload the build log is archived with a timestamp suffix (`docker.log.20240101_120000`); the last 5 are kept. Everything is deleted when the app is removed. App output does not go to journald. ## Other services JuiceFS, which backs the [archive tier](./data.md#the-archive-tier), runs as its own unit: `journalctl -u openhost-juicefs`. # Creating an App for Cloud in a Bottle This guide walks through building an app that runs on Cloud in a Bottle. ## Deploying your app From the dashboard, click "Deploy New App" and provide a git repo URL (public or private - private GitHub repos will prompt for auth). The router reads `cloudinabottle.toml`, builds the container image from your `Dockerfile` using rootless podman, and starts routing requests to it. Apps are accessible at `https://{app_name}.{zone_domain}/` (e.g. `https://my-app.mycooldomain.com/`). ## Writing an app to run on Cloud in a Bottle Apps can be anything that can run in an OCI container, and accessed via HTTP(s). Cloud in a Bottle runs every app under rootless podman, so container-root maps to an unprivileged subuid on the host rather than real root. A `cloudinabottle.toml` manifest must be placed at the root of your repo, to indicate to Cloud in a Bottle how to run your app. See the [manifest spec](manifest_spec.md) for the full field reference. ## App template Cloud in a Bottle has a standard app template available as a starting point: [https://github.com/cloud-in-a-bottle/app-template](https://github.com/cloud-in-a-bottle/app-template) This repo contains a Python 3.12 server using Litestar and Hypercorn, managed with uv. It includes pre-commit hooks (ruff formatting, mypy strict type checking), and an integration test suite using pytest, httpx, and Playwright. Tests run both locally and as a full containerized app, building the Dockerfile and fronting it with the real Cloud in a Bottle router. The template is recommended when starting a new app from scratch. Existing projects can also be deployed to Cloud in a Bottle directly via their own Dockerfile without using this template. Here's an example of a simple app: ### Directory structure ``` my-app/ ├── cloudinabottle.toml ├── Dockerfile ├── pyproject.toml # or package.json, go.mod, etc. ├── app.py # your app code └── entrypoint.sh # optional startup script ``` ### cloudinabottle.toml ```toml [app] name = "my-app" version = "0.1.0" description = "What it does" [runtime.container] image = "Dockerfile" # path to Dockerfile relative to repo root port = 8080 # port your app listens on inside the container [routing] public_paths = ["/webhook"] # routes accessible without auth [resources] memory_mb = 128 cpu_cores = 0.1 [data] sqlite = ["main"] app_data = true ``` ### Dockerfile ```dockerfile FROM python:3.12-alpine COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app COPY pyproject.toml . RUN uv sync COPY . . EXPOSE 8080 CMD ["uv", "run", "python", "-u", "app.py"] ``` ### App code Your app should listen on `0.0.0.0:` where `` matches `runtime.container.port` in the manifest. The router handles TLS and proxies requests to your container as HTTP. ```python from flask import Flask import os app = Flask(__name__) @app.route("/") def index(): return "

Hello from Cloud in a Bottle

" if __name__ == "__main__": app.run(host="0.0.0.0", port=8080) ``` ### Notes - Apps are available at `{app_name}.{compute_space_url}`. - Data directories are mounted into the container at `/data/`. See [Environment variables](#environment-variables) below. - The router handles authentication. By default, all routes require the compute space owner to be logged in. To make specific routes public, list them in `public_paths` in the manifest. - For apps that implement their own auth, routes can be set as public, and requests that have been authenticated by the router will bear a `X-OpenHost-Is-Owner=true` header. - To surface interesting paths on your app (e.g. an admin console) to the user on the dashboard, declare them in `[[links]]` (each with a `name` and `path`). See the [manifest spec](manifest_spec.md). ### Environment variables The router injects these environment variables into your app. | Variable | Example | Description | |----------|---------|-----------------------------------------------------------------------------------------------------------------| | `BOTTLE_APP_NAME` | `my-app` | Your app's name, as registered with Cloud in a Bottle. This will be the subdomain the app is routeable at. | | `BOTTLE_APP_ID` | `4Hm9pX2Qk7Lt` (12-char base58) | Opaque, immutable per-app identity. Stable across renames; safe to key persistent state on. | | `BOTTLE_APP_TOKEN` | `kF3xP_2qA-bN4...` (43-char url-safe token) | Random per-app token used to authenticate cross-app service calls | | `BOTTLE_ROUTER_URL` | `http://host.containers.internal:8080` | internal URL of the router, used for constructing service requests. | | `BOTTLE_LOCAL_PORT` | `9137` | The host port the router expects the app on. Set only for `network_host` apps, which must bind this instead of their manifest port | | `BOTTLE_ZONE_DOMAIN` | `mycooldomain.com` | The instance's domain | | `BOTTLE_MY_REDIRECT_DOMAIN` | `my.selfhost.imbue.com` | The shared `my.*` OAuth redirect domain. This hosts a browser-local page that redirects the user to their zone. | | `BOTTLE_APP_DATA_DIR` | `/data/app_data/my-app` | Path to the app's persistent data directory. Set when `app_data` (default on), `sqlite`, or `access_all_app_data` is requested | | `BOTTLE_APP_TEMP_DIR` | `/data/app_temp_data/my-app` | Path to the app's temporary data directory. Set when `app_temp_data` or `access_all_app_data` is requested | | `BOTTLE_APP_ARCHIVE_DIR` | `/data/app_archive/my-app` | Path to the app's elastic archive directory. Set when `app_archive` or `access_all_app_data` is requested and the archive mount is available | | `BOTTLE_SQLITE_` | `/data/app_data/my-app/sqlite/main.db` (for `sqlite = ["main"]`) | Path to a provisioned SQLite database file. Set once per entry in `sqlite` | | `BOTTLE_OWNER_USERNAME` | `alice` | The compute space owner's chosen display name. Use to seed SSO account names. Defaults to `owner` if not explicitly configured. | ### Data storage Apps have three storage areas, each with different durability + size + latency tradeoffs. By default, apps receive a permanent data directory (`app_data`). Other tiers must be explicitly requested via the `[data]` section of their manifest: - **Permanent data** (mounted at `BOTTLE_APP_DATA_DIR`): local disk. Small, fast, backed up. Enabled by default. - **Temporary data** (mounted at `BOTTLE_APP_TEMP_DIR`): local disk scratch. Not backed up, recreatable. Enabled by `app_temp_data = true`. - **Archive data** (mounted at `BOTTLE_APP_ARCHIVE_DIR`): bulk content storage. Backed by local disk by default, but the owner can configure a S3 bucket (which is mounted with JuiceFS as a POSIX-compatible filesystem) from the dashboard for elastic, durable object storage. Higher-latency on uncached reads once on S3, although JuiceFS yields relatively performant access once cached. Intended for apps that store bulk content (videos, photos, attachments) that may overload local storage, and where low latency isn't critical. Enabled by `app_archive = true`. Apps can additionally request `access_all_app_data`, giving read/write access to every app's permanent, temporary, and archive data. This is necessary for apps like file browsers or backup apps. The retired `access_all_data` and `access_all_archive` fields are deprecated aliases for this permission. All data dirs are mounted under `/data/` in the container. All apps see the same path structure regardless of permissions; only the dirs they have access to are mounted. The directory structure contains folders like `/data/app_data/{app_name}`, `/data/app_temp_data/{app_name}`, `/data/app_archive/{app_name}`. The env vars `BOTTLE_APP_*_DIR` should be preferred to hardcoding paths. See the [manifest spec](manifest_spec.md) for the full reference. ### Services See [Cross-App Services](./cross_app_services.md) for how services work. ## Development / Debugging workflow In general, the debugging flow is something like: 1. Create your app in its own repo 2. Install it into your compute space (from the dashboard or CLI) 3. Test it 4. Fix bugs / make changes, commit and push 5. "Update and reload" from the app details page (pulls new code and rebuilds) 6. Retest and repeat There is a CLI interface, `bottle`, that can be used for interacting with your compute space, if you prefer that style of workflow. See [The bottle CLI](../operation/cli.md) to install it and log in. # Cloud in a Bottle Manifest Spec Apps declare how they should be deployed on Cloud in a Bottle by placing a `cloudinabottle.toml` file at the root of their git repository. This file defines the spec of the manifest in detail; for a more general walkthrough of creating an app, see [Creating an App](./overview.md). ## Basic Example ```toml [app] name = "my-app" version = "0.1.0" description = "A simple web app" [runtime.container] image = "Dockerfile" port = 8080 [resources] memory_mb = 128 cpu_cores = 0.1 [data] sqlite = ["main"] ``` ## Field Reference ### `[app]` (required) | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | yes | Unique app identifier (lowercase, hyphens ok) | | `version` | string | yes | Version string. Conventionally semver, but only checked for non-emptiness (not validated as semver). | | `description` | string | no | Short description | | `authors` | string[] | no | List of author names | ### `[runtime.container]` (required) | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `image` | string | yes | - | Path to Dockerfile relative to repo root | | `port` | integer | yes | - | Port the app responds to HTTP on | | `command` | string | no | - | Override container CMD | | `capabilities` | string[] | no | `[]` | **Additional** Linux capabilities to grant inside the container, on top of the Docker-default baseline (CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT, NET_RAW, MKNOD, AUDIT_WRITE) that every container receives automatically. Restricted to a rootless-safe allowlist (see `compute_space.core.manifest.SAFE_CAPABILITIES`); disallowed entries like `"SYS_ADMIN"` are rejected at parse time. Accepts names with or without the `CAP_` prefix. | | `devices` | string[] | no | `[]` | Host devices to pass through (e.g., `"/dev/net/tun"`). Restricted to a rootless-safe allowlist (see `compute_space.core.manifest.SAFE_DEVICE_PATHS`); disallowed paths like `/dev/mem`, `/dev/kvm`, or raw block devices are rejected at parse time. | ### `[[ports]]` (optional, repeatable) Declares additional port mappings for the container. Each entry binds a container port to a host port (TCP+UDP on 0.0.0.0). Set `host_port = 0` for auto-assignment from the 9000-9999 range. For normal HTTP/HTTPS routing, this section is not necessary. Traffic will enter the app through through the router proxy - requests to `https://{app_name}.{zone_domain}/` will be routed to the app container at port `runtime.container.port`. Specifying additional ports in this section is only necessary if the app requires a non-HTTP protocols (e.g. SMTP on `25`). We prefer to write apps that only use HTTP-compatible protocols whenever possible. Only host ports >= 25 can be bound. Ports `80` and `443` are reserved by the router and therefore can't be bound by apps. Multiple apps requesting the same host port can't be installed at the same time, therefore prefer auto-assigning (`host_port = 0`) when possible to avoid conflicts. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `label` | string | yes | - | Unique label for this port mapping (e.g., `"metrics"`) | | `container_port` | integer | yes | - | Port inside the container | | `host_port` | integer | no | `0` | Port on the host (0 = auto-assign) | ### `[routing]` (optional) | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `health_check` | string | no | - | Health check path. Used to determine when the app has finished booting up. | | `public_paths` | string[] | no | `[]` | Route prefixes accessible without authentication | ### `[[links]]` (optional, repeatable) A convenience feature to display additional links to the instance owner on the app detail page. By default we just link to your app's root at `{app_name}.{zone_url}`. This feature allows additional links to be displayed, eg to an admin console at `/_openhost/admin`. The `path` is taken at face value and is not verified in any way. In general it's better to expose these links from within your app; this is mainly a convenience feature for supporting existing apps where we want to add a Cloud in a Bottle specific admin page that isn't exposed in the upstream app. This feature may be removed in the future. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `name` | string | yes | - | Display name for the link (e.g., `"admin"`) | | `path` | string | yes | - | Path on the app's URL (e.g., `"/_openhost/admin"`) | ### `[resources]` (optional) | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `memory_mb` | integer | no | 128 | Max container memory in MB | | `build_memory_mb` | integer | no | `memory_mb` | Memory limit (MB) for the image build step. Defaults to the app's `memory_mb`; a build that needs more must set this explicitly. | | `cpu_cores` | float | no | 0.1 | CPU allocation in cores (1.0 = 1 core) | ### `[data]` (optional) | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `app_data` | boolean | no | true | Provision a directory for this app on the instance's file system, intended for persistent app state. This data will be included in backups and instance migrations. Exposed to the app via `BOTTLE_APP_DATA_DIR`. | | `app_temp_data` | boolean | no | false | Provision a directory for this app on the instance's file system, intended for ephemeral app data. This data will persist between container or instance boots, but will not be included in backups / instance migrations. Exposed to the app via `BOTTLE_APP_TEMP_DIR`. | | `app_archive` | boolean | no | false | Provision a directory for this app in the instance's "archive storage", intended for persistent but bulky content. Archive storage is backed by local disk by default, but can be configured by the owner to be served by remote S3 backend to enable larger and more reliable storage than the instance's local disk. Exposed to the app via `BOTTLE_APP_ARCHIVE_DIR`. | | `sqlite` | string[] | no | [] | SQLite databases to provision. Each entry provisions `app_data/sqlite/{name}.db`, exposed to the app as `BOTTLE_SQLITE_`. Enabling implicitly enables `app_data`. | | `access_all_app_data` | boolean | no | false | Mount the parent dirs for all apps' permanent, temporary, and archive data (rw). For admin, file browser, and backup apps. Mounted under `/data` in the container. | The retired `access_all_data` and `access_all_archive` fields are deprecated aliases for `access_all_app_data`. Manifests using either receive the full permanent, temporary, and archive data permission and emit a deprecation warning. ## More Examples ### App with extra container permissions ```toml [app] name = "ha-tunnel" version = "0.2.0" description = "WebSocket tunnel to Home Assistant" [runtime.container] image = "Dockerfile" port = 8080 [routing] public_paths = ["/tunnel"] [resources] memory_mb = 128 cpu_cores = 0.1 ``` ### App with extra port mappings ```toml [app] name = "monitoring" version = "0.1.0" [runtime.container] image = "Dockerfile" port = 8080 [[ports]] label = "metrics" container_port = 9090 host_port = 9090 [[ports]] label = "debug" container_port = 5005 host_port = 0 # auto-assigned ``` ### Minimal app (wrapping existing software) ```toml [app] name = "file-browser" version = "0.1.0" description = "Web-based file browser" [runtime.container] image = "Dockerfile" port = 5000 command = "/data -A" [data] access_all_app_data = true ``` ### App advertising user-facing links ```toml [app] name = "synapse" version = "0.1.0" [runtime.container] image = "Dockerfile" port = 3000 [[links]] name = "admin" path = "/_openhost/admin" ``` ## Cross-App Services Apps can expose services that other apps consume. The router (compute_space) mediates all cross-app communication; apps never talk directly to each other. A **service** is identified by a URL (typically a git URL pointing at a spec) plus a SemVer version. Multiple apps can implement the same service; the router resolves which provider to use per call. ### Quick start Declare what you want in your manifest: ```toml [[services.v2.consumes]] service = "github.com/imbue-openhost/openhost/services/secrets" shortname = "secrets" version = ">=0.1.0" grants = [{key = "MY_API_KEY"}] ``` The owner approves those grants when they install your app. Then call the service through the router, which resolves the provider and forwards your grants with the request: ```bash curl -X POST "$BOTTLE_ROUTER_URL/api/services/v2/call/secrets/get" \ -H "Authorization: Bearer $BOTTLE_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keys": ["MY_API_KEY"]}' ``` That is the whole consumer side. The rest of this page is the detail: how providers are selected, how permissions work, and how to write a provider. ### Service identity + spec Services are identified by URL, e.g. `github.com/imbue-openhost/openhost/services/secrets`. The URL is a git path (and optional subdirectory). Currently the URL is only used comparatively, to match providers and consumers. But probably in the future we'll fetch the git URL to lookup details about the service, eg from a manifest file. You should put documentation on the service specification at the service URL - ideally a formal openAPI spec, but informal documentation is allowable also. This should document the API endpoints, and also the structure of the permission grants consumer apps must acquire to use the service (these are up to the service to define). Versions follow SemVer. Providers declare a specific version; consumers declare a SemVer specifier (e.g. `>=0.1.0`). Major version indicate breaking changes; minor versions indicate backward-compatible changes. The git repo should have tags for each version (eg v1.1.1, or sub/dir:v1.1.1 if at a subdir). The services that ship with Cloud in a Bottle are specified in the repo's `services/` folder, each at its own service URL: | Service | URL | Spec | |---|---|---| | **secrets** | `github.com/imbue-openhost/openhost/services/secrets` | [openapi.yaml](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle/blob/main/services/secrets/openapi.yaml) | | **oauth** | `github.com/imbue-openhost/openhost/services/oauth` | [README](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle/blob/main/services/oauth/README.md), [openapi.yaml](https://github.com/cloud-in-a-bottle/cloud-in-a-bottle/blob/main/services/oauth/openapi.yaml) | ### Provider apps Provider apps declare what services they offer in their manifest: ```toml [[services.v2.provides]] service = "github.com/imbue-openhost/openhost/services/secrets" version = "0.1.0" endpoint = "/api/" ``` Service requests land rooted at `endpoint` in the provider app, ie `app-name.your-domain.com//`. ### Consumer apps Consumer apps declare what they consume, with a `shortname` they'll use to call it and the permission grants they're requesting: ```toml [[services.v2.consumes]] service = "github.com/imbue-openhost/openhost/services/oauth" shortname = "oauth" version = ">=0.1.0" grants = [ {provider = "google", scopes = ["https://www.googleapis.com/auth/gmail.readonly"]}, {provider = "github", scopes = ["repo"]}, ] ``` Each entry in `grants` is either an opaque string (e.g. `"read"`) or a TOML/JSON object (e.g. `{key = "DB_URL"}`). Strings work well for simple flag-style permissions; objects are for grants with structured fields. The shape is defined by the service, not the router: providers receive the raw grants verbatim and decide what they mean. `shortname` must match `^[a-z][a-z0-9_-]{0,31}$` and be unique within the manifest. ### Calling a service ``` GET|POST|WS|... [BOTTLE_ROUTER_URL]/api/services/v2/call// ``` along with `Authorization: Bearer $BOTTLE_APP_TOKEN` header for server-side requests. `BOTTLE_ROUTER_URL` is provided to apps as an env var. This endpoint is app-specific - the router loads the consumer's manifest, finds the `[[services.v2.consumes]]` entry matching ``, resolves the correct provider of the requested service, and proxies to `provider_app.BOTTLE_ROUTER_URL//`. The router identifies and authenticates the calling app two ways: - **Server-side calls:** must include `Authorization: Bearer $BOTTLE_APP_TOKEN`. Each app gets a unique `BOTTLE_APP_TOKEN` injected as an env var at deploy time. - **Browser calls:** the request's `Origin` is matched against the app's subdomain, with the owner session cookie authenticating the user. No bearer token is needed for these; the browser provides the cookie automatically. Service calls should be API-only - the user's browser should never be redirected to a service endpoint, with the exception of permission grant pages. If permission is needed to access the service, a 403 is returned - see the Permissions section below. ### Provider selection Each service URL has one default provider, resolved in this order: 1. the provider the owner picked in Cloud in a Bottle's settings; 2. the router's builtin, if the router implements the service itself (see below); 3. the app that has provided the service the longest. Only the owner's choice is stored; steps 2 and 3 are derived on each call, so a service starts working the moment something provides it and keeps working when the app that was serving it is uninstalled. Installing a provider never makes it the default on its own: a second provider of a service holds its own data, so it sits alongside the incumbent (reachable via `X-OpenHost-Provider`) until the owner switches over. If nothing provides the service at all, or the resolved default's version doesn't satisfy the consumer's version specifier, the router returns 503 `service_not_available`. #### Builtin providers The router can provide a service itself instead of proxying to an app. A builtin looks like any other provider to a consumer (same call path, same headers, same permission model), and appears in the provider listings under the app ID `_openhost_router`. It holds a service until the owner points that service at an app, and takes it back over when the owner clears that choice. #### Calling a specific provider To call a non-default provider, include the `X-OpenHost-Provider` header with the target app's `app_id`: ``` GET [BOTTLE_ROUTER_URL]/api/services/v2/call// Authorization: Bearer $BOTTLE_APP_TOKEN X-OpenHost-Provider: ``` If the specified provider doesn't exist for that service, or its version doesn't match the consumer's version specifier, the router returns 503 `service_not_available`. Omitting the header uses the default provider as before. #### Discovering providers Apps can list all providers for a service using the discovery endpoint (see Management API below). This is useful when aggregating data across multiple providers of the same service. ### Permissions Permissions are **opaque grant payloads** (strings or JSON objects), scoped per `(consumer_app, service_url)`. The router stores grants and forwards the granted set (those that apply to the calling app and service URL) to the provider on every call, but **the provider is what enforces access**, not the router. This lets services define whatever permission shape they need. **Grant scope** is one of: - `global`: applies to **all providers** of the given service. This is the scope for manifest-declared permission grants. - `app`: applies only to a **specific provider** app. These are often data-dependent permissions, eg "access email for me@example.com", where that data only lives in a specific provider app, so a global scoped permission wouldn't make sense. **On every proxied call, the router injects:** - `X-OpenHost-Consumer-Name: ` - `X-OpenHost-Consumer-Id: ` - `X-OpenHost-Permissions: ` Each entry in the permissions array is `{"grant": , "scope": "global"|"app"}`. The router pre-filters the array: every `scope: "app"` entry the provider sees is one the router resolved as addressed to *this* provider. The internal `provider_app_id` field is stripped before forwarding, since the provider is the addressee by construction. Strict providers may still want to reject `scope: "global"` entries entirely if every legitimate grant for them flows through their own consent UI (the oauth service does this). #### Global-scoped grants When a consumer calls without sufficient grants, the provider returns: ```json HTTP 403 { "error": "permission_required", "required_grant": { "grant": { ... }, "scope": "global" } } ``` For `scope: "global"`, the router rewrites the response to add a `grant_url` pointing at the owner-facing approval page. For `scope: "app"`, the provider must include its own `grant_url` (see below). The consumer redirects the owner to `grant_url`; after approval, the call can be retried. **Granting at deploy time.** Global-scoped permissions specified in the consumer app manifest (`grants`) can be granted as part of the app install, either in the Cloud in a Bottle web UI or via the compute_space CLI's `--grant-permissions-v2` flag. #### Provider-app-scoped permissions Because app-scoped grants are often data-dependent ("this consumer may access photos in *this* folder", "*this* email inbox", "*this* set of files"), the provider is responsible for the whole approval UX. The router only stores the resulting grant. This also allows a provider to ensure that its data can't be accessed by a permission granted to a different provider of this service, if that's desired. It gives this provider full control over access to its own data. There's currently no way to grant these at install time of a consumer app (since consumers can potentially interact with multiple providers). When a consumer calls without sufficient grants, the provider returns: ```json HTTP 403 { "error": "permission_required", "required_grant": { "grant": { ... }, "scope": "app", "grant_url": GRANT_URL } } ``` Note for `scope: "app"`, the provider must include its own `grant_url`. 1. **Consumer hands the user off.** The consumer redirects the user's browser to the `grant_url` returned in the 403. The consumer should arrange for a `return_to` URL on its own subdomain to be propagated to that page (typical convention: include `return_to=https://./...` on the request to `grant_url`) 2. **Provider renders a consent page.** This is a normal page in the provider app: the user is on `.` with the owner cookie. The page should: - State plainly *which consumer app* is asking and *exactly what data* it's asking for. The narrower and more concrete, the better ("Grant *photos-app* read access to the folder `Vacation/Italy`?" beats "Grant *photos-app* read access?"). - Let the user shape the grant where it makes sense (pick which folder, which inbox, which subset of items, etc.). - Run any side flows the grant needs, e.g. an OAuth dance with a third party, picking a row from the provider's own DB, prompting for a passphrase. 3. **Provider creates the grant.** Once the user confirms (and any side flow has completed), the provider's backend calls: ``` POST [BOTTLE_ROUTER_URL]/api/permissions/v2/grant_app_scoped Authorization: Bearer $BOTTLE_APP_TOKEN Content-Type: application/json {"consumer_app_name": "", "service_url": "", "grant": } ``` Identify the consumer by **name**: the value from the `X-OpenHost-Consumer-Name` header, which is also what your consent page should have shown the user. Keying the grant to the name is what makes that page trustworthy: the field the user read is the field the access is granted to, so a page that names the wrong app grants to the wrong app instead of to itself. An unknown name is a 404. The router takes the provider's own app ID from the bearer token, so the provider can only grant permissions *for itself*; it can't create grants attributed to another provider. The grant body is the same shape the provider will later see in `X-OpenHost-Permissions`. str or json can be used. 4. **Provider sends the user back.** After the grant call succeeds, the provider should redirect the user's browser to the consumer-supplied `return_to`. The consumer can then retry the original service call, which will now succeed. If the user declines, the provider should also redirect to `return_to` (without creating a grant) so the consumer can show its own "permission denied" UI rather than leaving the user stranded on the provider's page. ### Management API These endpoints back the owner-facing UI and are authenticated by the owner login cookie unless otherwise noted. Bodies and responses are JSON. **Permissions** - `GET /api/permissions/v2[?app_id=]`: list grants, optionally filtered to one consumer app. Returns an array of `{consumer_app_id, service_url, grant, scope, provider_app_id}`. - `POST /api/permissions/v2/grant_global_scoped`: grant a global-scoped permission. Body: `{app_id, service_url, grant}`. - `POST /api/permissions/v2/grant_app_scoped`: grant an app-scoped permission. **Authenticated with the calling provider's app token** (not the owner cookie); the provider's app ID is taken from the token. Body: `{consumer_app_name, service_url, grant}` (404 if no app has that name). Used by provider apps after running their own user-facing approval flow (e.g. an OAuth dance). - `POST /api/permissions/v2/revoke`: revoke a permission. Body: `{app_id, service_url, grant, scope?, provider_app_id?}`. `scope` defaults to `"global"`. 404 if no matching row. The `grant` field on these endpoints is whatever shape the service defines, and is passed through the router verbatim. **Default provider** Each service URL has at most one default provider. Calls without an explicit provider use it; see Provider selection above for how it's resolved when the owner hasn't picked one. Both listing endpoints return an array of `{service_url, app_id, app_name, service_version, endpoint, status, is_default}`, with the router's builtins included as providers (`app_id: "_openhost_router"`, always `running`). - `GET /api/services/v2`: list every provider of every service. - `GET /api/services/v2/providers?service=`: the same, narrowed to one service. Accepts both owner auth and app bearer tokens, so consumer apps can discover providers at runtime. - `POST /api/services/v2/defaults`: set the default. Body: `{service_url, app_id}`. Pass `_openhost_router` to hand the service to the router's builtin. 404 if that provider doesn't actually provide the service. - `DELETE /api/services/v2/defaults`: clear the owner's choice. Body: `{service_url}`. Selection then falls back to the builtin, or to the longest-serving provider app if there isn't one; calls return 503 if nothing is left to serve them. ### Retrofitting existing apps Many existing apps provide or consume APIs in a way that is not native to Cloud in a Bottle, and can be adapted readily to consume or expose these through the service interface. #### Consumer apps Consumer apps need to include the `Authorization` header on server-side requests. This can be added by a little reverse proxy running in in the application container, like [`mitmproxy`](https://github.com/mitmproxy/mitmproxy). ```sh # matches the [[services.v2.consumes]] entry in your manifest. mitmdump -p 9000 \ --mode reverse:$BOTTLE_ROUTER_URL/api/services/v2/call/ \ --set "modify_headers=/~q/Authorization/Bearer $BOTTLE_APP_TOKEN" ``` Then point the app at `http://localhost:9000` and a request to `http://localhost:9000/target_api_endpoint` will reach `$BOTTLE_ROUTER_URL/api/services/v2/call//target_api_endpoint` with the bearer token attached. #### Provider apps Provider apps should verify permissions attached to inbound requests. A simple permission structure might just requires a string grant `FULL_ACCESS`, and can be implemented with a Caddyfile rule that verifies that the request header (something like `X-OpenHost-Permissions=[{"grant": "FULL_ACCESS", "scope": "global", ...}]`) contains this permission before passing on to the app's existing API: ``` :8080 { @denied not header_regexp X-OpenHost-Permissions "\"grant\"\\s*:\\s*\"FULL_ACCESS\"" handle @denied { header Content-Type application/json respond `{"error":"permission_required","required_grant":{"grant":"FULL_ACCESS","scope":"global"}}` 403 } reverse_proxy localhost:3000 }```