Keep in mind, now infra project works with new VM all changes will apply to VM by ${HOST} variable.
We build real uncloud here.
The project with all scripts for setup an infrastructure for simple pet project, without over engineering, but with all required components, such as backups for database, backup for reverse proxy etc.
The project prepares VPS for hosting docker stack by Ansible scripts. All infrastructure components deploy as docker services. Also configuration provides presetup docker networks.
So basicly you just rent any VPS or setup the ubunut server your self, and have it up and running for few minutes.
The project provides all infrastruture require to implement simple pet projects without over engineering. All setup of VPS done with infrastructure as code approatch which help you replicate the infra on any ubuntu server without pain.
The infrastructure includes the following:
- Docker Swarm
- Postgress
- Caddy reverse proxy
- Grafana
- Centralized logs for every container (Loki + Grafana Alloy)
The project provides caddy as reverse proxy for handle SSL key generation and allow you to host few services on one VPS.
MongoDB is my database of choice. I believe it's the only data store which you need to build the project. I use the mongodb bucket for BLOB store, it make local infracture easy to handle, and the API for mongo bucket easy to work compare to S3 buckets.
In production the MongoDB API is served by FerretDB 1.24
(ferretdb service) with Azure Database for PostgreSQL as storage. FerretDB keeps everything in one
postgres database ferretdb (create it once in Azure): every mongo database is a postgres schema,
every collection is a table, every document a jsonb row. No new postgres database is created per
mongo database.
FerretDB has no users of its own, it forwards the client credentials to postgres. Connect with the
postgres user and the PLAIN mechanism, from a service joined to infra_mongo-rs-net:
mongodb://<USERNAME>:<PASSWORD>@ferretdb:27017/<db>?authMechanism=PLAIN
FerretDB is not a replica set: drop replicaSet=rs0 from connection strings, change streams
(watch) and transactions are not available.
All container logs and per-container resource metrics (CPU, memory, network, disk I/O) of the swarm are collected by Grafana Alloy (alloy/config.alloy) and shipped directly to Grafana Cloud (Loki + Prometheus):
- alloy (
grafana/alloy) runs inglobalmode (one instance per swarm node).- Streams container stdout/stderr from the Docker API to Grafana Cloud Loki (
GRAFANA_LOKI_URL). - Exposes per-container cAdvisor metrics and remote-writes the essential container resource metrics to Grafana Cloud Prometheus (
GRAFANA_PROMETHEUS_URL).
- Streams container stdout/stderr from the Docker API to Grafana Cloud Loki (
# everything of one service
{service="infra_caddy"}
# everything of the infra stack, only the lines containing "error"
{stack="infra"} |~ "(?i)error"
# how many lines per second each service produces
sum by (service) (rate({stack="infra"}[5m]))
Every log line and container metric carries three labels:
| label | meaning |
|---|---|
service |
swarm service name (infra_caddy), or container name if not swarm |
stack |
stack the service belongs to (infra, nu31space, ...) |
node |
swarm node the container runs on |
The infra stack has the cron executor crazymax/swarm-cronjob which is used for periodic backup database and caddy config
The project provides periodic backups for mongo and caddy config. The Database backups each hour, each day, and each week, all backups are stored in a Google Cloud Storage bucket.
Backups authenticate with a Google Cloud service account that has the Storage Object Admin role on the bucket. Provide its credentials to the deploy as:
vars.GCS_BUCKET— the destination bucket name (nogs://prefix).secrets.GCS_SA_KEY_BASE64— the service account JSON key, base64-encoded (e.g.base64 -w0 key.json).
Restoring mongo is part of the infrastructure too: run the Restore from backup
GitHub action (.github/workflows/restore-backup.yml) by hand. Inputs:
target-mongo-weeklybackup_file-latest, a file name in the target's bucket folder (mongodump-2026-10-20_03-20.gz) or a fullgs://urlconfirm- the target typed again, the current data is replaced
The mongo backup image ships a restore.sh next to its backup.sh. The action
ssh-es to HOST and runs scripts/restore-on-host.sh, which starts a one-off container from
the image of the deployed backup service with that service's env, network and volumes. So the
restore reads the same bucket folder with the same credentials, and no secret leaves GitHub.
The stack has to be deployed first.
| target | what happens |
|---|---|
| mongo | mongorestore --drop of the archive into ferretdb, collections in the backup are replaced |
PostgreSQL is not backed up by the stack, the data lives in Azure Database for PostgreSQL, which keeps its own backups.
Moving to a new VPS: run Setup VPS, deploy the stack, then restore each target. The manual
steps are kept in RESTORE_MONGO.md.
Two workflows:
.github/workflows/build-images.ymlholds the list of all images of the infra. It runs on every pull request and only builds the images, so a broken Dockerfile is caught in the pull request instead of breaking the deploy of main. One failing image does not cancel the others (fail-fast: false)..github/workflows/deploy-infra.ymlruns on push tomain. It callsbuild-images.ymlwithpush: trueto build and push all images to ghcr.io, and then deploys the stack over ssh.
Because both flows use the same image list, a green pull request means the deploy will build the same set of images successfully.
For more details, take a look at project github action
You can find all required environment variables and secrets for the project in the github action job Create env file. Also some secrets are provided as docker secrets, but I have been trying to get rid of docker secrets in the project.
To create own infrastructure setup, fork the repository and update all need github action secrets and variables.
node mqtt.js --mode=read --topic=my/topic
node mqtt.js --mode=write --topic=my/topic --message="hello world"
The mqtt-logger service (mqtt-logger/logger.ts) subscribes to # and stores every
message into the mqtt_history table of the mqtt_store PostgreSQL database. Messages
are kept for 24 hours (RETENTION_HOURS), older rows are deleted every 10 minutes.
Open the MQTT history dashboard in Grafana and type a topic filter into the
Topic box, using the same syntax as an MQTT subscription:
| filter | shows |
|---|---|
# |
every topic |
prismo/# |
prismo and everything below it |
sensors/+/temperature |
one level wildcard |
prismo/door/state |
exactly that topic |
The same filter works in your own SQL through the mqtt_topic_matches(topic, filter)
function:
SELECT received_at, topic, payload
FROM mqtt_history
WHERE mqtt_topic_matches(topic, 'prismo/#')
ORDER BY received_at DESC;Users and their permissions live in the mosquitto dynamic security plugin
(dynsec.json in the mqtt-data volume), so they can be added on the running
broker, no restart needed. MQTT has no "create topic" step: a topic is a role that
allows publish + subscribe on <topic>/#, given to a new user.
Run on the VPS (replace sensors/kitchen and kitchen-esp):
docker exec -it $(docker ps -qf name=infra_mqtt.1) sh -c '
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec createRole topic:sensors/kitchen &&
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec addRoleACL topic:sensors/kitchen publishClientSend "sensors/kitchen/#" allow &&
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec addRoleACL topic:sensors/kitchen subscribePattern "sensors/kitchen/#" allow &&
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec createClient kitchen-esp &&
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec addClientRole kitchen-esp topic:sensors/kitchen'createClient asks for the new user's password. The device then connects to
mqtts://mqtt.nu31.space:8883 with these credentials and can publish and subscribe on
sensors/kitchen/# only, any other topic is denied.
To give one more user access to an existing topic, run only the last two commands.
To check the result, open a shell in the broker container
(docker exec -it $(docker ps -qf name=infra_mqtt.1) sh) and run:
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec listClients
mosquitto_ctrl -u "$MQTT_ADMIN_USER" -P "$MQTT_ADMIN_PASSWORD" dynsec getRole topic:sensors/kitchen