βοΈ Cloud Staging Deployment
Goal: deploy smart-home-cloud to a public VPS with HTTPS/WSS and auto-deploy from GitHub, ready for stations and the iOS app to connect.
Time estimate: 3β4 hours of active work + up to 1 hour waiting for DNS/TLS. Starting budget: ~$15 (domain $10 + first month of VPS $5).
Stage 0. Prerequisitesβ
- 0.1. GitHub account with the private
smart-home-cloudrepo pushed to thedevelopbranch (ormain). - 0.2. Local SSH key. Check:
ls ~/.ssh/id_ed25519.pub. If missing βssh-keygen -t ed25519 -C "alphaoflogic@gmail.com"(Enter through all prompts). - 0.3. International payment card (Visa/MC, Wise/Monobank FX). Check that the limit is at least $20.
- 0.4.
dockeranddocker composeinstalled locally (for the first smoke build):docker --version,docker compose version. - 0.5.
curlanddig/nslookupinstalled:which curl dig.
Stage 1. Domain (15 min + 5β60 min of DNS waiting)β
- 1.1. Open https://www.cloudflare.com/products/registrar/ (or https://www.namecheap.com if Cloudflare doesn't accept the card).
- 1.2. Pick a name:
<slug>.com(for examplesmartstation-lab.com). Check availability. - 1.3. Pay for 1 year of registration. Disable auto-renew right away so you don't get charged automatically for a second year.
- 1.4. In Cloudflare: Domain β DNS β empty for now. Enable Full (strict) SSL mode in SSL/TLS β Overview, but disable Proxy (grey cloud) for
api.staging.*β Cloudflare Proxy breaks WebSocket connections from stations. - 1.5. Save the domain in your notes:
DOMAIN=<chosen domain>. From here on I'll write<DOMAIN>.
Verification: dig <DOMAIN> NS +short returns Cloudflare (or Namecheap) NS servers.
Stage 2. VPS on Hetzner (20 min)β
- 2.1. Sign up: https://accounts.hetzner.com/signUp. Need an email + card. Initial validation may require ID (passport photo).
- 2.2. Once the account is approved: Hetzner Cloud β https://console.hetzner.cloud β New Project β "smart-home-staging".
- 2.3. In the project β Security β SSH Keys β Add SSH Key β paste the contents of
~/.ssh/id_ed25519.pub(locallycat ~/.ssh/id_ed25519.pub). - 2.4. Servers β Add Server:
- Location: Helsinki (hel1) or Nuremberg (nbg1) β closer to Ukraine in terms of latency.
- Image: Ubuntu 24.04.
- Type: CX22 (x86, 2 vCPU, 4 GB RAM, 40 GB disk) β β¬4.51/mo.
- Networking: leave both IPv4 and IPv6 enabled.
- SSH Key: pick the key you just added.
- Name:
cloud-staging. - Create & Buy Now.
- 2.5. Wait for status Running (~30 sec). Copy the IPv4 address into your notes:
SERVER_IP=<x.x.x.x>.
Verification: ssh root@<SERVER_IP> connects without a password. Exit: exit.
Stage 3. DNS records (5 min + up to 60 min propagation)β
- 3.1. Cloudflare β DNS β Add record:
- Type: A, Name:
api.staging, Content:<SERVER_IP>, Proxy: DNS only (grey cloud), TTL: Auto.
- Type: A, Name:
- 3.2. Another record:
- Type: A, Name:
wss.staging, Content:<SERVER_IP>, Proxy: DNS only, TTL: Auto.
- Type: A, Name:
- 3.3. (Alternative) If you decide to use a single host for HTTP+WS β one
api.stagingrecord is enough; the WS upgrade will happen on the same domain at the/wspath.
Verification: dig api.staging.<DOMAIN> +short returns <SERVER_IP> (may take 1β5 minutes).
Stage 4. VPS hardening (15 min)β
All commands run on the VPS over SSH.
- 4.1.
ssh root@<SERVER_IP> - 4.2. System update:
apt update && apt -y full-upgradeapt -y install ufw fail2ban unattended-upgrades curl gnupg ca-certificates
- 4.3. Automatic security updates:
dpkg-reconfigure --priority=low unattended-upgrades
- 4.4. Firewall:
ufw default deny incomingufw default allow outgoingufw allow 22/tcpufw allow 80/tcpufw allow 443/tcpufw --force enableufw status verbose
- 4.5. Disable password SSH (the key is already in place):
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_configsed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_configsystemctl restart ssh
- 4.6. Create the deploy user:
adduser --disabled-password --gecos "" deployusermod -aG sudo deploymkdir -p /home/deploy/.sshcp /root/.ssh/authorized_keys /home/deploy/.ssh/chown -R deploy:deploy /home/deploy/.sshchmod 700 /home/deploy/.sshchmod 600 /home/deploy/.ssh/authorized_keysecho 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy
Verification: ssh deploy@<SERVER_IP> works, sudo whoami returns root.
Stage 5. Docker + Docker Compose (5 min)β
As the deploy user:
- 5.1. Install Docker:
curl -fsSL https://get.docker.com | sudo shsudo usermod -aG docker deploy
- 5.2. Re-login so the group takes effect:
exitssh deploy@<SERVER_IP>docker run --rm hello-world
Verification: docker compose version returns Docker Compose version v2.x.
Stage 6. Staging config (locally, 20 min)β
All actions happen in your local smart-home-cloud repo.
- 6.1. Create
docker-compose.staging.yml(see template at the end of the file). - 6.2. Create
Caddyfileat the repo root:(Caddy will fetch a Let's Encrypt TLS cert for this domain on its own.)api.staging.<DOMAIN> {reverse_proxy cloud:4000} - 6.3. Create
.env.staginglocally (do NOT commit). Contents:NODE_ENV=productionPORT=4000DB_HOST=postgresDB_PORT=5432DB_USER=smarthomeDB_PASSWORD=<generate: openssl rand -hex 24>DB_NAME=smart_home_cloudJWT_SECRET=<openssl rand -hex 32>JWT_REFRESH_SECRET=<openssl rand -hex 32>JWT_EXPIRES_IN=15mJWT_REFRESH_EXPIRES_IN=30dGOOGLE_CLIENT_ID=placeholder-not-used-yetAPPLE_CLIENT_ID=com.andriiprudnikov.smarthomemobile - 6.4. Add
.env.stagingto.gitignore(verify it's already covered by.env.*). - 6.5. Commit Dockerfile + .dockerignore + docker-compose.staging.yml + Caddyfile:
git add Dockerfile .dockerignore docker-compose.staging.yml Caddyfilegit commit -m "SHC-XX staging deployment: docker + caddy"git push
Verification: docker build -t cloud-test . builds locally without errors.
Stage 7. First deploy (manual, 15 min)β
- 7.1. SSH to the server:
ssh deploy@<SERVER_IP> - 7.2. Create the directory:
mkdir -p ~/smart-home-cloudcd ~/smart-home-cloud
- 7.3. Clone the repo (via deploy key or PAT):
# Option with HTTPS + Personal Access Token:git clone https://<GITHUB_USER>:<PAT>@github.com/<GITHUB_USER>/smart-home-cloud.git .# Or set up a deploy key beforehand.
- 7.4. Copy
.env.stagingfrom your local machine to the server:# On the local machine:scp .env.staging deploy@<SERVER_IP>:~/smart-home-cloud/.env - 7.5. Edit
Caddyfileon the server β substitute the actual<DOMAIN>:nano Caddyfile - 7.6. Bring it up:
docker compose -f docker-compose.staging.yml --env-file .env up -d --builddocker compose -f docker-compose.staging.yml logs -f
- 7.7. The logs should show
Server listening at http://0.0.0.0:4000andSuccessfully connected to PostgreSQL. Migrations apply automatically (there's arunMigrations()call in server.ts).
Verification:
curl -I https://api.staging.<DOMAIN>/health
# HTTP/2 200
# content-type: application/json
curl https://api.staging.<DOMAIN>/health
# {"status":"ok"}
If TLS didn't come up β check the Caddy logs: docker compose logs caddy. Common reasons: DNS hasn't propagated yet, or Cloudflare Proxy is enabled (grey/orange cloud β should be grey).
Stage 8. GitHub Actions auto-deploy (20 min)β
- 8.1. On the server, generate a separate SSH key for CI:
ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N ""cat ~/.ssh/github_deploy.pub >> ~/.ssh/authorized_keyscat ~/.ssh/github_deploy # β private key, copy in full
- 8.2. In GitHub β
smart-home-cloudrepo β Settings β Secrets and variables β Actions β add:SSH_HOST=<SERVER_IP>SSH_USER=deploySSH_KEY= the private key contents (including-----BEGIN OPENSSH...-----)
- 8.3. Create
.github/workflows/deploy-staging.yml(see template at the end of the file). - 8.4. Push:
git push origin developβ Actions β see the run start β success in ~2β3 minutes.
Verification: make a test commit (e.g. tweak docs/cloud-spec.md), push, confirm the workflow goes green and docker compose logs on the server shows the container restarting.
Stage 9. Monitoring and verification (15 min)β
- 9.1. UptimeRobot (free): https://uptimerobot.com β Add Monitor β HTTPS β
https://api.staging.<DOMAIN>/health, interval 5 min, email alert. - 9.2. Check WS:
# locally:npm i -g wscatwscat -c wss://api.staging.<DOMAIN>/ws/station# Should connect. Close with Ctrl+C.
- 9.3. Connect a real station to staging: on the Raspberry Pi set
CLOUD_WSS_URL=wss://api.staging.<DOMAIN>/ws/stationin its.env, then restart withdocker compose restart. - 9.4. In the cloud logs
docker compose logs -f cloudyou should seeclaim_handshakefrom the station. - 9.5. (optional) Set up a daily Postgres backup on the server β a cron script with
pg_dumpwriting into~/backups/. Retention 7 days.
Template: docker-compose.staging.ymlβ
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${DB_USER}']
interval: 10s
timeout: 5s
retries: 5
cloud:
build: .
restart: unless-stopped
env_file: .env
depends_on:
postgres:
condition: service_healthy
expose:
- '4000'
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- cloud
volumes:
pgdata:
caddy_data:
caddy_config:
Template: .github/workflows/deploy-staging.ymlβ
name: Deploy staging
on:
push:
branches: [develop]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
cd ~/smart-home-cloud
git fetch origin develop
git reset --hard origin/develop
docker compose -f docker-compose.staging.yml --env-file .env up -d --build
docker image prune -f
Final verification (green checks)β
-
https://api.staging.<DOMAIN>/healthβ 200 OK - TLS certificate is valid (lock icon in the browser)
-
wscatconnects to WSS - Push to
developβ GitHub Actions green β container rebuilt - UptimeRobot reports status "up"
- (once mobile is ready) Claim QR code works β identity sync arrives at the station