Small hosting for non-critical websites:
- h+h lab landing
- Development preview of Slow Reader
- Browserslist REPL
- Sitnik personal website
- Logux website
Stack:
- Ubuntu 26.04 LTS + Canonical Livepatch
- Ansible
- Rootless Podman/Quadlet for each service
- gVisor for the pull request previews
- Caddy web server and load balancer
- Automatic updates for low maintenance.
- Accept downtime, but keep it short.
- No backups or duplication needed.
All services run under systemd with auto-restart.
Each website has a separate user and can run multiple apps. Each app has its own image, containers, domain, and deployment.
Web services use public Podman images. Containers are read-only, have no capabilities, and limit memory, CPU, and processes to protect the server.
Images should define a HEALTHCHECK. Podman kills unresponsive containers;
systemd restarts them. Old deployment images are removed nightly.
After publishing an image:
- GitHub Actions sends an HTTP request; we verify its GitHub origin and repository.
- Podman pulls and starts the image, checks its health, and switches the domain to it.
- The API waits for deployment and returns its log. Broken images fail the workflow.
Databases and tools like Redis also run in Podman, with rolling tags
such as :9 updated automatically by Podman tools.
Each database belongs to one website and exposes no host port. It listens only on a private Podman network inside that user’s network namespace.
A pull request can run a single image on its own subdomain,
such as preview-42.slowreader.hplush.dev.
Because pull request code is unreviewed, previews have:
- A separate user, no database, no shared network, and no host access.
- A shared memory limit for all previews.
- Routes written by the root
preview-routewrapper, never the preview user. The wrapper validates the PR number and port, then uses its own Caddy template. - gVisor, a userspace kernel, to handle syscalls. Escaping a regular container can require only a host kernel bug; previews require a gVisor bug as well.
Network syscalls use the host kernel within each preview’s own network namespace, not the host’s network namespace. The user’s firewall rules still apply. This is needed because gVisor’s network stack cannot use pasta’s tap device, which would leave published ports unreachable.
gVisor costs memory and network throughput. Websites and databases keep the default runtime because we build their images ourselves.
Previews stop when their PR closes. A daily timer also removes previews
not redeployed for max_days days (default: 30), limiting their lifetime even
if the cleanup workflow fails.
The deploy API is a custom Node.js HTTP server. Its source files live on the server and run in an automatically updated Node.js image.
inventory.yml: server address and SSH account.requirements.txt: Ansible CLI versions.requirements.yml: Ansible collection versions..vault-pass: Ansible Vault password; create locally, excluded from Git.group_vars/all.yml: server-wide settings.websites/: one config per website, named after its domain.previews/: one config per preview type, named after its parent domain.site.yml: playbook calling all roles.roles/base/: updates, Livepatch,fail2ban, firewall, Podman, gVisor, users, journal limits, and daily image cleanup.roles/caddy/: Caddy and domain configs.roles/api/: internal web API for GitHub Actions.roles/web/: website user, two containers, and deploy script.
-
Create a cheap server with 4 GB memory and Ubuntu 26.04 LTS.
-
Create firewall rules:
- Public:
ICMP,TCP 80,TCP 443,UDP 443(HTTP/3 QUIC) - Admin IP only:
TCP 22
- Public:
-
Add
AandAAAADNS records forhplush.dev. -
Add
CNAMErecords forcloudandapi.cloudpointing tohplush.dev. -
Update the system:
ssh root@cloud.hplush.dev apt update && apt upgrade -y sudo reboot now -
Create the admin user:
ssh root@cloud.hplush.dev adduser ai usermod -aG sudo ai mkdir -p /home/ai/.ssh cp /root/.ssh/authorized_keys /home/ai/.ssh/ chown -R ai:ai /home/ai/.ssh chmod 700 /home/ai/.ssh chmod 600 /home/ai/.ssh/authorized_keys exit -
Create
known_hosts:ssh-keyscan cloud.hplush.dev > known_hosts ssh-keygen -lf known_hosts -
Generate the Ansible Vault password in
.vault-pass:pnpm dlx nanoid --size 32 > .vault-pass chmod 600 .vault-pass -
Encrypt the Ubuntu Pro token and add the output to
group_vars/all.yml:ansible-vault encrypt_string --name ubuntu_pro_token 'YOUR_TOKEN'
Deploy, entering the server user’s password when prompted:
ansible-playbook site.yml --user ai --ask-become-passThe playbook is idempotent and preserves the container serving each domain.
- Copy
websites/hplush.dev.ymltowebsites/YOUR_DOMAIN.yml. - Set the user, image, allowed GitHub repository/workflow/branch, container port, and a free pair of host ports.
- Point the domain’s
AandAAAArecords to the server and deploy changes.
Default container limits: 512 MB memory, one CPU, and 512 processes.
Caddy requests a Let's Encrypt certificate on the first request; HTTPS requires working DNS.
After publishing an image, get a GitHub OIDC token and call the deploy API. Each app deploys independently through its domain endpoint, which accepts only its configured workflow:
permissions:
id-token: write
concurrency:
group: deploy-hplush.dev
cancel-in-progress: false
steps:
# Some steps of preparing the image
- name: Deploy image
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let token = await core.getIDToken('https://api.cloud.hplush.dev')
let response = await fetch(
'https://api.cloud.hplush.dev/deploy/hplush.dev',
{ method: 'POST', headers: { authorization: `Bearer ${token}` } }
)
let answer = await response.text()
if (response.ok) core.info(answer)
else core.setFailed(`${response.status} ${response.statusText}: ${answer}`)See full example.
- Copy
previews/slowreader.hplush.dev.ymland configure it. - Point wildcard
AandAAAArecords for*.YOUR_DOMAINto the server. - Create a
previewlabel; apply it to PRs only after basic review. - Use a
pull_requestworkflow without permissions to build a Docker image and upload it as an artifact. - Use a
workflow_runworkflow to download the artifact, push it with a preview tag, and request deployment. The server verifies the workflow:
# Download artifact from pull_request workflow and push it with tag
- name: Deploy the preview
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let token = await core.getIDToken('https://api.cloud.hplush.dev')
let response = await fetch(
`https://api.cloud.hplush.dev/deploy/preview-${process.env.PR}.slowreader.hplush.dev`,
{ method: 'POST', headers: { authorization: `Bearer ${token}` } }
)
let answer = await response.text()
if (response.ok) core.info(answer)
else core.setFailed(`${response.status} ${response.statusText}: ${answer}`)On PR close, a pull_request workflow with type closed triggers
a workflow_run workflow that sends DELETE to the same endpoint:
- name: Clean the preview
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let token = await core.getIDToken('https://api.cloud.hplush.dev')
let response = await fetch(
`https://api.cloud.hplush.dev/deploy/preview-${process.env.PR}.slowreader.hplush.dev`,
{ method: 'DELETE', headers: { authorization: `Bearer ${token}` } }
)
let answer = await response.text()
if (response.ok) core.info(answer)
else core.setFailed(`${response.status} ${response.statusText}: ${answer}`)Both requests wait for the result; a preview startup failure fails the workflow.
See examples:
Automatic updates:
unattended-upgradesinstalls packages nightly.needrestartrestarts services using old libraries.- Livepatch patches the running kernel.
These never reboot the server; new kernels require a manual reboot.
Check monthly. SSH logins show *** System restart required *** when
needed. Inspect pending restarts:
ssh ai@cloud.hplush.dev
sudo needrestart -r lReboot to use the new kernel:
sudo rebootIf the kernel needs no reboot, restart everything listed, including user managers. Websites will be down for a few seconds:
sudo needrestart -b -r l | awk -F': ' '/^NEEDRESTART-SVC/{print $2} /^NEEDRESTART-SESS/{split($2,u," ");c="id -u "u[1];c|getline i;close(c);print "user@"i".service"}' | sort -u | xargs -r sudo systemctl restartFind failed services:
systemctl --failedServices run as separate users; read their logs in the system journal:
sudo journalctl -u caddy
sudo journalctl _SYSTEMD_USER_UNIT=api.service
sudo journalctl _SYSTEMD_USER_UNIT=deploy-hplush.service
sudo journalctl _SYSTEMD_USER_UNIT=hplush-blue.service
sudo journalctl _SYSTEMD_USER_UNIT=slowreader-db.service
sudo journalctl _SYSTEMD_USER_UNIT=preview-42.serviceUnits use app names: slowreader-server has
slowreader-server-blue.service and deploy-slowreader-server.service.
Open a website’s database:
sudo -u slowreader podman exec -it slowreader-db psql -U slowreaderDeploy manually by creating a request file:
sudo touch /var/lib/deploy/hplush.dev/requests/manualStart or stop a preview manually:
echo 'deploy 42' | sudo tee /var/lib/deploy/previews/slowreader.hplush.dev/requests/manual
echo 'clean 42' | sudo tee /var/lib/deploy/previews/slowreader.hplush.dev/requests/manualThe deploy script removes the request file, writes the result to
/var/lib/deploy/hplush.dev/results/manual, and logs it to the journal.