Small hosting for non-critical websites:
- h+h lab landing
- Development preview of Slow Reader
- Browserslist REPL
- Sitnik personal website
- Logux website
- Nano Stores website
- Self-hosted Plausible Analytics
- Grafana with Slow Reader production charts
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.
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.
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 (or custom health_cmd for OCI images).
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.
Plausible Community Edition
runs as three containers of the plausible user: Plausible itself,
PostgreSQL for accounts and settings, and ClickHouse for the events.
The databases expose no host port, listen only on a private Podman network,
and have generated passwords.
All three images use rolling tags and update automatically. Plausible migrates both databases on every start. PostgreSQL and ClickHouse versions follow the Docker Compose file of Plausible; check it before changing them.
Grafana runs as one container of the grafana user, with SQLite
in a volume. Its image uses the latest tag and updates automatically.
It reads VictoriaMetrics on the Slow Reader server with the built-in
Prometheus data source: Slow Reader Server for server metrics and
Slow Reader App for users and events.
Alerts go to Telegram (dashboards_telegram_token and
dashboards_telegram_chat). Rules are in
rules.alerting.yml.j2.
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, swap, Podman, gVisor, users, journal limits, and daily image cleanup.roles/caddy/: Caddy and domain configs.roles/api/: internal web API for GitHub Actions.roles/analytics/: Plausible Analytics with PostgreSQL and ClickHouse.roles/dashboards/: Grafana and its dashboards.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 forcloud,api.cloud,lens, anddashboardpointing 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:
ansible-playbook site.yml --user ai --ask-become-pass
-
Right after the first deploy, open lens.hplush.dev and register the Plausible admin account.
-
Open dashboard.hplush.dev and log in as
aiwith the password from the server:sudo cat /home/grafana/.local/state/secrets/admin-password
- 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
deploy-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:
Only the first account, which you register after the first deploy, needs no invitation.
Plausible has no email, so an invited person gets no link. Invite them on the site’s “People” settings page, then get the link from the database:
sudo -u plausible podman exec plausible-db psql -U plausible -c \
'select invitation_id from guest_invitations'Send them https://lens.hplush.dev/register/invitation/INVITATION_ID.
Reset a forgotten password from the server:
sudo -u plausible podman exec -it plausible /app/bin/plausible remotePlausible.Repo.get_by!(Plausible.Auth.User, email: "EMAIL")
|> Plausible.Auth.User.set_password("NEW_PASSWORD")
|> Plausible.Repo.update!()Automatic updates:
unattended-upgradesinstalls packages nightly.needrestartrestarts services using old libraries.- Livepatch patches the running kernel.
- Every day at 04:00 UTC the server reboots for a new kernel, but only if Livepatch can’t protect the running kernel.
To reboot earlier:
ssh ai@cloud.hplush.dev
sudo needrestart -r l
sudo reboot # if neededIf 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.service
sudo journalctl _SYSTEMD_USER_UNIT=plausible.service
sudo journalctl _SYSTEMD_USER_UNIT=plausible-events.service
sudo journalctl _SYSTEMD_USER_UNIT=grafana.serviceUnits use app names: slowreader-app has
slowreader-app-blue.service and deploy-slowreader-app.service.
Open a website’s database:
sudo -u slowreader podman exec -it slowreader-db psql -U slowreaderOpen the databases of Plausible:
sudo -u plausible podman exec -it plausible-db psql -U plausible
sudo -u plausible podman exec -it plausible-events clickhouse-client --database plausible_events_db --ask-passwordTheir passwords are in /home/plausible/.local/state/secrets/.
Reset the Grafana password:
read -rs PASSWORD
printf %s "$PASSWORD" | sudo -u grafana podman exec -i grafana \
grafana cli admin reset-admin-password --password-from-stdin
unset PASSWORDDeploy 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.