The project is a fully automated deployment of a Simple Inventory REST API built w/ Rust & Axum framework. The service stores inventory items in PostgreSQL, is reverse-proxied by Nginx, and is managed by systemd w/ socket activation. The entire stack is provisioned from scratch via Vagrant & Bash.
| Variable | Formula | Result | Meaning |
|---|---|---|---|
| V2 | (11 % 2) + 1 = 2 |
2 | PostgreSQL + YAML config file |
| V3 | (11 % 3) + 1 = 3 |
3 | Simple Inventory application |
| V5 | (11 % 5) + 1 = 2 |
2 | Port 5200 |
client → nginx (0.0.0.0:80) → app (127.0.0.1:5200) → PostgreSQL (127.0.0.1:5432)
| Component | Bind address | Port |
|---|---|---|
| Nginx (reverse proxy) | 0.0.0.0 |
80 |
| Axum web application | 127.0.0.1 |
5200 |
| PostgreSQL | 127.0.0.1 |
5432 |
- Nginx listens on port 80, proxies only the root (
/),/items, and/items/{id}endpoints, and returns404for health check paths and anything else. - The application runs as the restricted system user
appfrom/opt/mywebapp, started via systemd socket activation (mywebapp.socket→mywebapp.service). - PostgreSQL binds to localhost only with
scram-sha-256authentication — no external access. - Configuration lives at
/etc/mywebapp/config.yml(YAML, readable byrootandapponly).
git clone https://github.com/0xSERGEANT/mywebapp
cd mywebapp
vagrant upinstall.sh sources each deploy/lib/NN_*.sh step in numeric order:
- Preflight — OS and network checks
- Packages —
build-essential,postgresql,nginx,openssh-server, Rust toolchain viarustup - Users — creates
student,teacher(full sudo),operator(restricted sudo),app(system user, no shell) - PostgreSQL — localhost-only binding, creates role
mywebapp_userand databasemywebapp_database - Build —
cargo build --release; binary installed to/opt/mywebapp/mywebapp - Config — writes
/etc/mywebapp/config.ymlfrom template with generated DB password - Migrate — runs
migrate.shas theappuser (idempotent, version-tracked) - systemd — installs socket + service units, enables and starts them, verifies
/health/alive - Nginx — deploys allow-list site config, reloads nginx
- Sudoers — installs
operator-mywebappfragment viavisudo -c - Lockout — writes
/home/student/gradebook, locks the defaultvagrantuser - Verify — smoke-tests all endpoints from inside the VM
Once complete, the service is accessible on the host at:
http://localhost:8080/
The installer runs 99_verify.sh automatically. To re-run it manually:
sudo bash /vagrant/deploy/lib/99_verify.shExpected output:
PASS health alive (loopback) (200)
PASS health ready (loopback) (200)
PASS root via nginx (200)
PASS items via nginx (json) (200)
PASS health blocked externally (404)
PASS unknown path 404 (404)
PASS dotfile 404 (404)
99_verify.sh: all post-install checks passed
| Resource | Value |
|---|---|
| Base image | debian/bookworm64 |
| CPU | 2 vCPUs |
| RAM | 1024 MB |
| Disk | ~10 GB (default Vagrant box allocation) |
The stack can be deployed via Docker Compose using containerized services connected through a custom bridge network (mywebapp_net):
db: PostgreSQL 17 database running on Alpine Linux.app: Containerized Rust/Axum backend server.nginx: Alpine-based reverse proxy configuration.
- Docker
- Docker Compose
Generate your configuration from the provided blueprint:
cp .env.example .envLaunch the stack in the background:
docker compose up -d --buildCheck container status:
docker compose psTrace logging indicators directly to the console buffer:
docker compose logs -fTear down active structural layers:
docker compose down(Append -v to discard internal volume state mappings permanently).
The application reads /etc/mywebapp/config.yml (path overridable via MYWEBAPP_CONFIG env var):
database:
host: "127.0.0.1"
port: 5432
user: "mywebapp_user"
password: "<generated at provision time>"
database: "mywebapp_database"
server:
host: "127.0.0.1"
port: 5200The file is owned root:app, mode 0640. The raw password is also stored in /etc/mywebapp/.pgpass (mode 0600, root-readable only) so the install scripts can re-run idempotently.
| User | Purpose | Privileges |
|---|---|---|
student |
Project owner / development | Full sudo; forced password reset on first login |
teacher |
Grading / inspection | Full sudo; forced password reset on first login |
operator |
Service management only | Restricted sudo — see below |
app |
Application runtime | System user — no login shell, no home directory |
Default password for student, teacher, and operator: 12345678
A password change is enforced on first login (chage -d 0).
operator may only run (all require --no-pager):
systemctl --no-pager start mywebapp
systemctl --no-pager stop mywebapp
systemctl --no-pager restart mywebapp
systemctl --no-pager status mywebapp
systemctl --no-pager reload nginx
systemctl --no-pager status nginx
CREATE TABLE IF NOT EXISTS "items" (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS "idx_items_name" ON "items" (name);The GET /items list endpoint returns only id and name (ItemListEntry). The GET /items/{id} detail endpoint returns all four fields (Item).
mywebapp.socket binds 127.0.0.1:5200. When a connection arrives, systemd passes the file descriptor to the application via LISTEN_FDS (fd 3). The application falls back to binding the address from config if LISTEN_FDS is not set (useful for local development).
mywebapp.service runs two ExecStartPre steps before the main process:
wait_for_pg.sh— pollspg_isreadyup to 60 smigrate.sh— applies any pending.sqlmigrations from/opt/mywebapp/migrations/
All business-logic endpoints inspect the Accept header:
Accept value |
Response format |
|---|---|
text/html |
Plain HTML page (<table> for lists, no JS/CSS) |
application/json (or anything else) |
JSON (default) |
The root endpoint (GET /) requires Accept: text/html and returns 406 Not Acceptable otherwise.
| Method | Path | Notes |
|---|---|---|
GET |
/ |
Lists available endpoints. Requires Accept: text/html. |
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/items |
— | Array of {id, name} |
POST |
/items |
{"name": "...", "quantity": N} |
201 with full item {id, name, quantity, created_at} |
GET |
/items/{id} |
— | Full item or 404 |
| Method | Path | Description |
|---|---|---|
GET |
/health/alive |
Always 200 OK with body OK |
GET |
/health/ready |
200 OK if DB responds to SELECT 1, otherwise 500 |
All commands assume vagrant up has completed and the host port is 8080.
# Root (HTML required)
curl -H "Accept: text/html" http://localhost:8080/
# Create an item
curl -X POST http://localhost:8080/items -H "Content-Type: application/json" -d '{"name": "Cisco Router", "quantity": 10}'
# List items — JSON (default)
curl http://localhost:8080/items
# List items — HTML table
curl -H "Accept: text/html" http://localhost:8080/items
# Get a single item
curl http://localhost:8080/items/1
# Health endpoints are blocked externally (returns 404):
curl -i http://localhost:8080/health/alive
# Health endpoints work on the internal port inside the VM:
vagrant ssh -c "curl http://127.0.0.1:5200/health/alive"
vagrant ssh -c "curl http://127.0.0.1:5200/health/ready"Two-tier deployment on local KVM virtual machines.
WSL2 host (192.168.100.1)
│
│ libvirt NAT (192.168.100.0/24) — management + internet
├──────────────────────────────────────────────┐
│ │
┌────┴────────────────────┐ ┌──────────────┴──────────────┐
│ worker (192.168.100.10)│ │ db (192.168.100.11) │
│ │ │ │
│ nginx :80 │ │ PostgreSQL :5432 │
│ ↓ allow-list proxy │ │ listen: 10.0.1.11 only │
│ app container :5200 │ │ auth: scram-sha-256 │
│ ↓ env-vars config │ │ hba: worker private IP │
│ │ └──────────────────────────────┘
└─────────────┬────────────┘ │
└───────────────────────────────────┘
libvirt isolated (10.0.1.0/24)
worker: 10.0.1.10 ↔ db: 10.0.1.11
Users on every VM: ansible (SSH key, full sudo), student (local only), teacher (sudo + SSH password), operator (6 restricted sudo cmds, worker only).
Operator commands: systemctl start/stop/restart/status mywebapp, systemctl reload/status nginx
- KVM + libvirt + mkisofs
- Terraform 1.15.x
- Ansible 2.21+
# 1. Provision VMs
cd iac/terraform
cp example.tfvars terraform.tfvars # fill in ssh_public_key
terraform init && terraform apply
# 2. Wait for cloud-init (~60 s)
ssh ansible@192.168.100.10 cloud-init status --wait
ssh ansible@192.168.100.11 cloud-init status --wait
# 3. Prepare Ansible vault
cd ../ansible
ansible-galaxy collection install -r requirements.yml
cp inventory/group_vars/all/vault.yml.example \
inventory/group_vars/all/vault.yml
nano inventory/group_vars/all/vault.yml # set real passwords
ansible-vault encrypt inventory/group_vars/all/vault.yml
# 4. Deploy (WSL2: set ANSIBLE_CONFIG to bypass world-writable warning)
ANSIBLE_CONFIG="$(pwd)/ansible.cfg" \
ansible-playbook -i inventory/hosts.ini site.yml --ask-vault-pass
# 5. Smoke test
curl -H 'Accept: text/html' http://192.168.100.10/
curl -H 'Accept: application/json' http://192.168.100.10/itemsRun the playbook a second time to confirm idempotency (zero changes).
cd iac/terraform && terraform destroy| Tool | Version |
|---|---|
| Terraform | 1.15.5 |
| libvirt provider | 0.8.1 |
| ansible-core | ≥ 2.21 |
| Ubuntu (guest) | 24.04 LTS |
| PostgreSQL | 16 |
| Docker Compose | v2 plugin |
SSH into the VM as teacher (default password 12345678; you will be forced to change it):
ssh -p 2222 teacher@127.0.0.1sudo -u postgres psql -d mywebapp_database -c "\d items"
sudo -u postgres psql -d mywebapp_database -c "\di"sudo -l -U operatorsudo cat /etc/mywebapp/config.ymlsystemctl cat mywebapp.socket
systemctl cat mywebapp.servicecat /home/student/gradebook
# Expected output: 11systemctl status mywebapp.service
systemctl status mywebapp.socket
systemctl status nginx