A system that allows running application servers at home and making them reachable from the internet via a cloud proxy — without opening any inbound firewall ports on the home network.
- A cloud server running on any typical cloud provider. It is the entry point for all HTTPS traffic.
- A home network — one or more hosts behind NAT that run the services you want to expose.
| Component | Location | Role |
|---|---|---|
| HAProxy | Cloud server | SNI-based HTTPS ingress on port 443. Routes traffic to per-home SSH tunnel ports via a runtime-updated map file. |
| Django + sshd | Cloud server | REST API and web UI for managing homes and proxy mappings. SSH server that accepts reverse tunnels from home networks. |
| Home Console | Home network | Django app that manages domains, TLS certificates, and SSH reverse tunnels. Reads connection config from a local YAML file. |
| Setup scripts | Home network | Standalone scripts that generate an SSH key pair and register the home with the cloud server. Run once before starting the Home Console. |
- A home operator runs the setup scripts to generate an SSH key pair and register their home with the cloud server. The cloud server creates a dedicated system user and tunnel endpoint; the scripts write the resulting connection details to
home/config/cloudlink.yaml. - The Home Console Django app is started. It reads
cloudlink.yamland is ready for use. - The operator adds a domain in the Home Console. The ACME flow runs automatically: a temporary SSH tunnel is opened, a proxy mapping is registered on the cloud server, Let's Encrypt issues a certificate via HTTP-01 challenge, then the temporary tunnel and mapping are torn down.
- The operator adds an HTTPS proxy entry for the domain and opens its SSH reverse tunnel. HAProxy's SNI map is updated immediately — no reload needed.
- Incoming HTTPS traffic hits HAProxy on port 443, which routes it by SNI hostname through the tunnel to the home service.
docker compose -f cloud/compose.yaml up --buildThis starts two containers:
- haproxy — listens on ports 80 and 443
- tunnelagent — Django API on port 8000, SSH server on port 8022
HAProxy must pass its health check before tunnelagent starts.
docker compose -f cloud/compose.yaml exec tunnelagent python /opt/app/manage.py migrate
docker compose -f cloud/compose.yaml exec tunnelagent python /opt/app/manage.py createsuperuserThe SQLite database is stored outside the container at cloud/django/var/db.sqlite3.
The migrate step also provisions the 10 home slots (indices 0–9) automatically via the data migration homes/migrations/0003_provision_homes.py.
Users self-register at http://<cloud-host>:8000/signup/. New accounts are created inactive and must be approved by an administrator before login is allowed.
To activate an account: go to the Django admin at http://<cloud-host>:8000/admin/, open the user, tick Active, and save.
The API is browsable via Swagger UI when running in debug mode:
- Swagger UI:
http://localhost:8000/api/schema/swagger/ - ReDoc:
http://localhost:8000/api/schema/redoc/ - OpenAPI schema:
http://localhost:8000/api/schema/
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/proxy-mappings/sync |
Re-sync all DB mappings to HAProxy |
| GET | /api/admin/proxy-mappings/haproxy |
Dump current live HAProxy SNI map |
| POST | /api/admin/homes/sync |
Reconcile DB homes with system SSH users |
The home/ directory contains everything needed to connect a home network to the cloud server.
home/
├── config/
│ ├── cloudlink.yaml # written by register_home.py — contains secrets, not committed
│ └── cloudlink.yaml.example # template showing all required fields
├── scripts/
│ ├── generate_keys.py # generate a dedicated SSH key pair for tunnel use
│ └── register_home.py # register with the cloud server, write cloudlink.yaml
└── django/ # Home Console Django app
├── cloudlink/ # config loading, cloud API client, dashboard
└── domains/ # domain, certificate, and tunnel management
- Python 3.11+
- Docker (required for certificate issuance)
- A registered account on the cloud server (see User accounts above)
- The
cloudathome-acmeDocker image — build it once:
docker build -t cloudathome-acme -f home/nginx.dockerfile home/Run this once on the home machine. It creates a dedicated key pair for CloudAtHome tunnel use and prints the public key.
python home/scripts/generate_keys.pyBy default the private key is written to ~/.ssh/cloudathome_ed25519. Use --output to choose a different path:
python home/scripts/generate_keys.py --output /path/to/keyUse --force to overwrite an existing key pair.
This script authenticates with the cloud server, claims a home slot, and writes the connection config to home/config/cloudlink.yaml.
python home/scripts/register_home.py \
--cloudserver-url https://cloud.example.com \
--username alice \
--password secret \
--public-key ~/.ssh/cloudathome_ed25519.pub \
--private-key ~/.ssh/cloudathome_ed25519On success it prints a summary:
Done. Configuration written to: home/config/cloudlink.yaml
home_slug : xK3mAbcDef9pQr
ssh_username : home02_alice
ssh_host : cloud.example.com:22
port range : 2200 – 2209
The generated cloudlink.yaml contains secrets (auth token, key path) and is gitignored. See home/config/cloudlink.yaml.example for the full schema.
cd home/django
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver 0.0.0.0:8001The Home Console is available at http://localhost:8001/. Django reads cloudlink.yaml at startup. If the file is missing or malformed, startup fails immediately with a clear error message.
Changing the config path: Set the
CLOUDLINK_CONFIGenvironment variable to an absolute path if you want to keepcloudlink.yamlsomewhere other thanhome/config/.
The Home Console has a pytest-based test suite under home/django/tests/.
Install test dependencies:
cd home/django
pip install -r requirements-test.txtRun all tests:
pytest tests/ -vSome tests (e.g. test_certbot_failure_with_fake_domain) use the real Docker daemon and require the cloudathome-acme image. ensure_image() builds it automatically if it is not already present — this may take a minute on the first run. Make sure Docker is running before executing the suite.
Go to Domains → Add domain and fill in:
- Domain name — the public domain (e.g.
mysite.example.com). DNS must already point to the cloud server. - Email address — used by Let's Encrypt for renewal notifications.
- Certificate output directory — absolute path on this machine where certificates will be stored (e.g.
/etc/cloudathome/certs).
Submitting triggers the ACME flow automatically:
- A free tunnel port is allocated from the home's assigned range.
- A temporary HTTP proxy mapping is registered on the cloud server.
- An SSH reverse tunnel is opened to that port.
- The
cloudathome-acmecontainer starts; certbot performs the HTTP-01 challenge. - The tunnel and mapping are torn down; the container is removed.
- The domain is updated with the certificate path and expiry date.
After a certificate exists for a domain, go to the domain detail page and click Add HTTPS proxy entry. Fill in:
- Public hostname — the domain HAProxy will route (usually the same as the domain name).
- Home port — the local port your service listens on (e.g.
443).
This creates the cloud proxy mapping. The tunnel is not opened automatically — click Open tunnel on the proxy entry to start it.
Each proxy entry on the domain detail page has an Open tunnel / Close tunnel button. Tunnels are OS-level SSH processes; their PIDs are stored in the database so they can be stopped cleanly even after a Django restart.
This walkthrough goes from a fresh cloud stack to a publicly reachable home service. It assumes the cloud server has a public IP and that mysite.example.com DNS points to it.
docker compose -f cloud/compose.yaml up --buildGo to http://<cloud-host>:8000/signup/ and register. Log in to the Django admin at http://<cloud-host>:8000/admin/ as the superuser, open the new user, tick Active, and save.
python home/scripts/generate_keys.py
# prints the public key; private key written to ~/.ssh/cloudathome_ed25519python home/scripts/register_home.py \
--cloudserver-url http://<cloud-host>:8000 \
--username alice \
--password secret \
--public-key ~/.ssh/cloudathome_ed25519.pub \
--private-key ~/.ssh/cloudathome_ed25519This writes home/config/cloudlink.yaml with the assigned SSH username, port range, and auth token.
docker build -t cloudathome-acme -f home/nginx.dockerfile home/ # first time only
cd home/django && source .venv/bin/activate
python manage.py migrate
python manage.py runserver 0.0.0.0:8001Go to http://localhost:8001/domains/add/. Enter mysite.example.com, your email, and a local cert path (e.g. /etc/cloudathome/certs). Submit and wait — the ACME flow runs automatically.
From the domain detail page click Add HTTPS proxy entry. Set the hostname to mysite.example.com and the home port to the port your service listens on (e.g. 443).
Click Open tunnel on the proxy entry.
curl https://mysite.example.comTraffic hits HAProxy on the cloud server, is routed by SNI through the SSH tunnel, and arrives at your home service.
This walkthrough exercises the cloud stack locally — no real domain or DNS needed. It manually opens an SSH tunnel and adds a proxy mapping via the cloud web UI, without using the Home Console at all.
docker compose -f cloud/compose.yaml up --buildGo to http://localhost:8000/signup/ and register. Log in to the Django admin at http://localhost:8000/admin/ as the superuser, open the new user, tick Active, and save.
Go to http://localhost:8000/login/. From the dashboard click Register a home, paste your SSH public key (e.g. the contents of ~/.ssh/id_ed25519.pub), and submit.
Note the assigned SSH username (e.g. home00_alice) and port base (e.g. 2000).
docker build -t cloudathome-home-sim -f home/nginx.dockerfile home/
docker run --rm -p 8443:80 cloudathome-home-simThis starts nginx on localhost:8443.
ssh -N -T -R 127.0.0.1:2000:localhost:8443 home00_alice@localhost -p 8022This forwards port 2000 on the cloud server → port 8443 on this machine. The command hangs — that is correct; it holds the tunnel open.
From the cloud dashboard click Add mapping and fill in:
- Hostname — any domain (e.g.
mysite.example.com) - Tunnel port — the port base from step 3 (e.g.
2000) - Scheme —
https
HAProxy's SNI map is updated immediately.
curl -k --resolve mysite.example.com:443:127.0.0.1 https://mysite.example.com--resolve injects the hostname into the TLS ClientHello without a real DNS entry. -k accepts the self-signed certificate. You should see the response from the local service.