Skip to content

YourBOX VPS Deployment

ADR-0010 stages YourBOX deployments through a docker compose stack on the VPS before Azure and the homelab Kubernetes cluster. This page documents the Ansible role layout that provisions the VPS, the variable hygiene model, the Molecule test scenarios, the Traefik HTTPS termination and the mandatory smoke test that gates promotion.

flowchart LR
Local[Local docker compose on workstation] --> Vps[VPS smoke test]
Vps --> Azure[Azure App Service staging]
Azure --> Homelab[Homelab Kubernetes production]

The VPS exercises HTTPS termination, ACME certificate rotation, public DNS anchoring and OIDC integration with Authentik. Local docker compose runs the same compose file with a self signed certificate and the form firewall.

The Ansible code lives in homelab-platform/ansible/. YourBOX adds three roles and a thin play. Reusable plumbing (Docker installation, Traefik bootstrap, baseline hardening) lives in the shared roles already used by the weather-app VPS play.

homelab-platform/ansible/
├── ansible.cfg
├── inventory/
│ ├── production/
│ └── staging/
├── playbooks/
│ └── yourbox-vps.yml # Thin play, role composition only
├── roles/
│ ├── common/
│ │ ├── baseline/ # SSH hardening, sysctl, sudo, fail2ban
│ │ ├── docker/ # Docker Engine + Compose plugin
│ │ └── traefik/ # Reused by weather-app and yourbox
│ ├── yourbox/
│ │ ├── secrets/ # SOPS decryption on tmpfs, age key handling
│ │ ├── runtime/ # docker compose pull + up, healthcheck wait
│ │ └── smoketest/ # End to end smoke flow (upload, playback, login)
│ └── testinfra_helpers/ # Shared testinfra fixtures
└── requirements.yml # Galaxy + collections (pinned)

The play file is a composition document. It contains no inline tasks.

---
- name: Provision YourBOX on the VPS
hosts: yourbox_vps
become: true
gather_facts: true
tags: [yourbox, vps]
roles:
- role: common/baseline
- role: common/docker
- role: common/traefik
- role: yourbox/secrets
- role: yourbox/runtime
- role: yourbox/smoketest

Each role splits its variables between defaults/main.yml (overridable) and vars/main.yml (internal). The play imports group vars and host vars from the inventory tree.

roles/yourbox/runtime/
├── defaults/main.yml # Overridable: image digest, replicas, port mapping
├── vars/main.yml # Internal: compose project name, volume paths
├── tasks/main.yml # Entry point, delegates to focused task files
├── tasks/pull.yml
├── tasks/up.yml
├── tasks/healthcheck.yml
├── handlers/main.yml # Compose restart handler triggered by notification
├── templates/compose.yml.j2
├── files/
├── meta/main.yml # Role dependencies declared explicitly
└── molecule/
├── default/
│ ├── molecule.yml
│ ├── converge.yml
│ ├── verify.yml
│ └── prepare.yml
└── README.md

A typical defaults/main.yml for yourbox/runtime:

---
yourbox_runtime_image_repository: registry.yourbox.site/yourbox
yourbox_runtime_image_tag: "0.4.2"
yourbox_runtime_image_digest: "" # Set per environment, required
yourbox_runtime_replicas: 1
yourbox_runtime_port: 8080
yourbox_runtime_compose_dir: /opt/yourbox
yourbox_runtime_health_url: "https://yourbox.example/health"
yourbox_runtime_health_retries: 30
yourbox_runtime_health_delay: 5

The matching vars/main.yml (internal):

---
yourbox_runtime_compose_project: yourbox
yourbox_runtime_compose_file_path: "{{ yourbox_runtime_compose_dir }}/compose.yml"
yourbox_runtime_compose_env_file: "{{ yourbox_runtime_compose_dir }}/prod.env"
yourbox_runtime_volume_root: /var/lib/yourbox

A missing yourbox_runtime_image_digest triggers a validation task at role entry. The play fails fast.

roles/yourbox/runtime/tasks/main.yml
---
- name: Validate required variables
ansible.builtin.assert:
that:
- yourbox_runtime_image_digest is match('^sha256:[a-f0-9]{64}$')
fail_msg: "yourbox_runtime_image_digest must be set to an sha256 reference"
- name: Pull the digest pinned image
ansible.builtin.import_tasks: pull.yml
- name: Apply compose stack
ansible.builtin.import_tasks: up.yml
notify: Reload compose stack
- name: Verify service health
ansible.builtin.import_tasks: healthcheck.yml

The handler:

roles/yourbox/runtime/handlers/main.yml
---
- name: Reload compose stack
community.docker.docker_compose_v2:
project_src: "{{ yourbox_runtime_compose_dir }}"
state: present
pull: missing

The yourbox/secrets role materializes the age private key on a tmpfs mount, decrypts prod.env.enc from the YourBOX repository, writes the cleartext file with restrictive permissions and unsets the key from memory at the end.

roles/yourbox/secrets/tasks/main.yml
---
- name: Ensure tmpfs mount for age keys
ansible.posix.mount:
src: tmpfs
path: "{{ yourbox_secrets_tmpfs_path }}"
fstype: tmpfs
opts: "size=1m,mode=0700"
state: mounted
- name: Materialize age key from vault
ansible.builtin.copy:
content: "{{ yourbox_secrets_age_key }}"
dest: "{{ yourbox_secrets_age_key_path }}"
mode: '0400'
owner: root
group: root
no_log: true
- name: Decrypt prod.env.enc
ansible.builtin.command:
cmd: sops --decrypt "{{ yourbox_secrets_encrypted_file }}"
environment:
SOPS_AGE_KEY_FILE: "{{ yourbox_secrets_age_key_path }}"
register: yourbox_secrets_decrypted
changed_when: false
no_log: true
- name: Write prod.env with restrictive permissions
ansible.builtin.copy:
content: "{{ yourbox_secrets_decrypted.stdout }}"
dest: "{{ yourbox_runtime_compose_env_file }}"
mode: '0400'
owner: root
group: root
no_log: true

The age key never lands on persistent storage. The tmpfs mount discards on reboot and the key is re materialized from Ansible Vault at the next play.

Every play that mutates the service ends with a probe. The runtime role’s healthcheck task waits up to two and a half minutes for the public URL to return 200.

roles/yourbox/runtime/tasks/healthcheck.yml
---
- name: Wait for YourBOX /health to return 200
ansible.builtin.uri:
url: "{{ yourbox_runtime_health_url }}"
status_code: 200
return_content: false
validate_certs: true
register: yourbox_runtime_health
until: yourbox_runtime_health.status == 200
retries: "{{ yourbox_runtime_health_retries }}"
delay: "{{ yourbox_runtime_health_delay }}"
- name: Verify TLS certificate validity window
ansible.builtin.command:
cmd: >
openssl s_client -servername {{ yourbox_runtime_host }} -connect {{ yourbox_runtime_host }}:443
< /dev/null 2>/dev/null
| openssl x509 -noout -checkend 1209600
register: yourbox_runtime_tls_check
changed_when: false
failed_when: yourbox_runtime_tls_check.rc != 0

A 14 day expiry warning fails the play. Traefik renews on its own; the check catches a stalled renewer before the certificate is in danger.

The yourbox/smoketest role exercises the full path from upload to playback. It runs after the runtime role converges and the healthcheck passes.

roles/yourbox/smoketest/tasks/main.yml
---
- name: Authenticate seeded user
ansible.builtin.uri:
url: "{{ yourbox_smoketest_host }}/login"
method: POST
body_format: form-urlencoded
body:
_username: "{{ yourbox_smoketest_user }}"
_password: "{{ yourbox_smoketest_password }}"
follow_redirects: none
status_code: 302
register: yourbox_smoketest_login
no_log: true
- name: Upload fixture video
ansible.builtin.uri:
url: "{{ yourbox_smoketest_host }}/catalog/upload"
method: POST
src: "{{ role_path }}/files/fixture.mp4"
headers:
Cookie: "{{ yourbox_smoketest_login.set_cookie }}"
status_code: 202
register: yourbox_smoketest_upload
- name: Wait for transcoding to publish the video
ansible.builtin.uri:
url: "{{ yourbox_smoketest_upload.location }}"
status_code: 200
register: yourbox_smoketest_status
until: (yourbox_smoketest_status.json.status | default('')) == 'published'
retries: 30
delay: 2

The fixture video is a 5 second clip committed under roles/yourbox/smoketest/files/. The smoke test runs in CI through the same Molecule scenario described below.

Each role ships a molecule/default/ scenario that converges the role inside a disposable Docker container running Debian 12 and verifies the result with testinfra.

roles/yourbox/runtime/molecule/default/molecule.yml
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: yourbox-runtime-debian12
image: docker.io/library/debian:12@sha256:<digest>
pre_build_image: false
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
command: /lib/systemd/systemd
provisioner:
name: ansible
config_options:
defaults:
callbacks_enabled: profile_tasks
inventory:
host_vars:
yourbox-runtime-debian12:
yourbox_runtime_image_digest: "sha256:<test-digest>"
verifier:
name: testinfra
directory: ../tests

The testinfra verification asserts the structural invariants of the role without depending on the YourBOX runtime itself.

molecule/tests/test_runtime.py
import pytest
def test_compose_file_is_present(host):
f = host.file("/opt/yourbox/compose.yml")
assert f.exists
assert f.mode == 0o644
assert f.user == "root"
def test_env_file_has_restrictive_permissions(host):
f = host.file("/opt/yourbox/prod.env")
assert f.exists
assert f.mode == 0o400
def test_docker_service_is_enabled(host):
s = host.service("docker")
assert s.is_enabled
assert s.is_running
@pytest.mark.parametrize("port", [80, 443])
def test_traefik_listens(host, port):
s = host.socket(f"tcp://0.0.0.0:{port}")
assert s.is_listening

Molecule runs the converge twice in CI to catch idempotency regressions.

Gate Tool When it runs
YAML lint yamllint (relaxed rules) pre commit, CI lint stage
Ansible lint ansible-lint --profile production pre commit, CI lint stage
Inventory schema ansible-inventory --list + jq CI lint stage
Molecule converge molecule test -s default CI test stage (per role)
testinfra verify Molecule verifier block CI test stage (per role)
Idempotency second converge in Molecule CI test stage
Smoke test yourbox/smoketest role on the VPS CI deploy stage

ansible-lint --profile production enables every rule including the strict ones (no shell unless declared, naming conventions, idempotency hints, no inline credentials, FQCN required for modules). A failing rule blocks the merge.

The ansible.cfg matches the profile:

[defaults]
inventory = inventory/staging
roles_path = roles
collections_path = collections
host_key_checking = True
forks = 10
stdout_callback = yaml
callbacks_enabled = profile_tasks, timer
retry_files_enabled = False
deprecation_warnings = True
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s

The collections requirements file pins every collection:

requirements.yml
---
collections:
- name: community.docker
version: 4.5.0
- name: community.general
version: 9.5.0
- name: ansible.posix
version: 2.0.0
- name: community.hashi_vault
version: 6.2.0

The compose file at roles/yourbox/runtime/templates/compose.yml.j2 is the single source of truth for the runtime topology. The same template renders locally and on the VPS through Ansible variables.

---
services:
traefik:
image: docker.io/library/traefik:3.4@sha256:{{ yourbox_runtime_traefik_digest }}
ports: ["80:80", "443:443"]
volumes:
- {{ yourbox_runtime_compose_dir }}/traefik.yml:/etc/traefik/traefik.yml:ro
- {{ yourbox_runtime_compose_dir }}/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik-acme:/letsencrypt
networks: [edge]
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 10s
timeout: 3s
retries: 5
yourbox:
image: {{ yourbox_runtime_image_repository }}:{{ yourbox_runtime_image_tag }}@{{ yourbox_runtime_image_digest }}
env_file: {{ yourbox_runtime_compose_env_file }}
depends_on:
postgres: { condition: service_healthy }
minio: { condition: service_started }
labels:
traefik.enable: "true"
traefik.http.routers.yourbox.rule: "Host(`{{ yourbox_runtime_host }}`)"
traefik.http.routers.yourbox.entrypoints: websecure
traefik.http.routers.yourbox.tls.certresolver: letsencrypt
traefik.http.services.yourbox.loadbalancer.server.port: "8080"
networks: [edge, backend]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
postgres:
image: docker.io/library/postgres:16-alpine@sha256:{{ yourbox_runtime_postgres_digest }}
environment:
POSTGRES_USER: yourbox
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: yourbox
secrets: [db_password]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U yourbox"]
interval: 5s
timeout: 3s
retries: 10
volumes: [postgres-data:/var/lib/postgresql/data]
networks: [backend]
meilisearch:
image: docker.io/getmeili/meilisearch:v1.10@sha256:{{ yourbox_runtime_meili_digest }}
environment:
MEILI_MASTER_KEY_FILE: /run/secrets/meili_master_key
secrets: [meili_master_key]
volumes: [meili-data:/meili_data]
networks: [backend]
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:7700/health || exit 1"]
interval: 10s
timeout: 3s
retries: 5
minio:
image: docker.io/minio/minio:RELEASE.2026-05-01T00-00-00Z@sha256:{{ yourbox_runtime_minio_digest }}
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER_FILE: /run/secrets/minio_user
MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_password
secrets: [minio_user, minio_password]
volumes: [minio-data:/data]
networks: [backend]
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 3s
retries: 5
worker:
image: {{ yourbox_runtime_image_repository }}:{{ yourbox_runtime_image_tag }}@{{ yourbox_runtime_image_digest }}
command: ["php", "bin/console", "messenger:consume", "async", "-vv"]
env_file: {{ yourbox_runtime_compose_env_file }}
depends_on: { yourbox: { condition: service_healthy } }
networks: [backend]
healthcheck:
test: ["CMD-SHELL", "pgrep -f messenger:consume"]
interval: 30s
timeout: 3s
retries: 3
networks:
edge:
backend:
volumes:
traefik-acme:
postgres-data:
meili-data:
minio-data:
secrets:
db_password: { file: ./secrets/db_password }
meili_master_key: { file: ./secrets/meili_master_key }
minio_user: { file: ./secrets/minio_user }
minio_password: { file: ./secrets/minio_password }

Every service ships a Docker healthcheck. Compose depends_on waits for service health before starting downstream services.

Traefik handles HTTPS termination and ACME issuance through the Let’s Encrypt HTTP challenge. The configuration files are templated by the common/traefik role and shared with weather-app.

The dynamic file adds the security headers middleware and a default rate limit:

---
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true
contentTypeNosniff: true
referrerPolicy: strict-origin
permissionsPolicy: "geolocation=(), camera=(), microphone=()"
rate-limit:
rateLimit:
average: 100
burst: 200

A make vps:teardown target invokes a teardown play that stops the compose stack and removes the volumes. The play is idempotent: rerunning it with the same image digest reports zero changed tasks. Bumping the digest triggers the runtime handler which performs a rolling restart of the matching services.