YourBOX VPS Deployment
Purpose
Section titled “Purpose”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.
Environment Position
Section titled “Environment Position”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.
Ansible Layout
Section titled “Ansible Layout”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/smoketestVariable Hygiene
Section titled “Variable Hygiene”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.mdA typical defaults/main.yml for yourbox/runtime:
---yourbox_runtime_image_repository: registry.yourbox.site/yourboxyourbox_runtime_image_tag: "0.4.2"yourbox_runtime_image_digest: "" # Set per environment, requiredyourbox_runtime_replicas: 1yourbox_runtime_port: 8080yourbox_runtime_compose_dir: /opt/yourboxyourbox_runtime_health_url: "https://yourbox.example/health"yourbox_runtime_health_retries: 30yourbox_runtime_health_delay: 5The matching vars/main.yml (internal):
---yourbox_runtime_compose_project: yourboxyourbox_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/yourboxA missing yourbox_runtime_image_digest triggers a validation task at role
entry. The play fails fast.
---- 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.ymlThe handler:
---- name: Reload compose stack community.docker.docker_compose_v2: project_src: "{{ yourbox_runtime_compose_dir }}" state: present pull: missingSecrets Role
Section titled “Secrets Role”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.
---- 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: trueThe 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.
Healthcheck Task
Section titled “Healthcheck Task”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.
---- 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 != 0A 14 day expiry warning fails the play. Traefik renews on its own; the check catches a stalled renewer before the certificate is in danger.
Smoke Test Role
Section titled “Smoke Test Role”The yourbox/smoketest role exercises the full path from upload to playback.
It runs after the runtime role converges and the healthcheck passes.
---- 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: 2The 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.
Molecule And testinfra
Section titled “Molecule And testinfra”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.
---dependency: name: galaxydriver: name: dockerplatforms: - 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/systemdprovisioner: 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: ../testsThe testinfra verification asserts the structural invariants of the role without depending on the YourBOX runtime itself.
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_listeningMolecule runs the converge twice in CI to catch idempotency regressions.
Quality Gates
Section titled “Quality Gates”| 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/stagingroles_path = rolescollections_path = collectionshost_key_checking = Trueforks = 10stdout_callback = yamlcallbacks_enabled = profile_tasks, timerretry_files_enabled = Falsedeprecation_warnings = True
[ssh_connection]pipelining = Truessh_args = -o ControlMaster=auto -o ControlPersist=60sThe collections requirements file pins every collection:
---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.0Compose Stack
Section titled “Compose Stack”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 And HTTPS
Section titled “Traefik And HTTPS”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: 200Cleanup And Idempotency
Section titled “Cleanup And Idempotency”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.