Foundation roles for VPS setup - system hardening, Docker, and common services.
ansible-galaxy collection install git+https://github.com/softure/VPS_FOUNDATION.git#vps_foundationOr add to requirements.yml:
collections:
- name: softure.vps_foundation
source: https://github.com/softure/VPS_FOUNDATION.git#vps_foundation
type: git| Role | Description |
|---|---|
system_hardening |
System hardening (SSH, firewall, fail2ban, users) |
docker_engine |
Docker and Docker Compose installation |
postgres |
PostgreSQL container deployment (with optional PgAdmin and init scripts) |
mssql |
Microsoft SQL Server container deployment |
rabbitmq |
RabbitMQ container with management UI (with optional config) |
caddy |
Caddy reverse proxy (with optional Cloudflare) |
seq |
Seq logging server |
typesense |
Typesense search engine |
portainer |
Portainer CE for Docker management |
supertokens |
SuperTokens authentication server |
sqlpad |
SQL query tool with web UI |
backrest |
Backrest backup manager |
cronicle |
Cronicle task scheduler with web UI |
redis |
Redis container with password auth and AOF persistence |
usertour |
Self-hosted UserTour (product tours, checklists, tooltips) |
traefik_edge |
Shared Traefik edge: its own compose project and network, Docker label routing, ACME, trusted proxy ranges |
cloudflare_origin |
Only Cloudflare reaches published ports 80/443 (DOCKER-USER rules), ranges refreshed by a systemd timer |
deploy_user |
Non-sudo deploy user whose authorized_keys holds one forced-command release key per app |
Every service uses a sensible default image (e.g. postgres:16-alpine). Each image can be overridden with an optional <name>_image_override variable. When set, it fully replaces the default image — provide the complete reference including registry and tag; when left empty (the default), the built-in image is used.
| Variable | Overrides |
|---|---|
postgres_image_override |
PostgreSQL image |
pgadmin_image_override |
PgAdmin image |
mssql_image_override |
MSSQL image |
rabbitmq_image_override |
RabbitMQ image |
caddy_image_override |
Caddy image (wins over both the default and Cloudflare variants) |
seq_image_override |
Seq image |
typesense_image_override |
Typesense image |
portainer_image_override |
Portainer image |
supertokens_image_override |
SuperTokens image |
sqlpad_image_override |
SQLPad image |
backrest_image_override |
Backrest image |
cronicle_image_override |
Cronicle image |
redis_image_override |
Redis image |
usertour_image_override |
UserTour image |
vars:
# Pull from a private registry, or pin/bump to a different tag
postgres_image_override: myregistry.io/library/postgres:17-alpine
# Just change the tag on the default image
typesense_image_override: typesense/typesense:0.26.0Ports published by Docker bypass UFW. On a host where nothing should be reachable from outside except
through a reverse proxy (e.g. a CI runner box), set docker_default_bind_ip: 127.0.0.1: ports published
without an address ("5432:5432") then listen on localhost only. Leave it empty (the default) on hosts
whose reverse proxy publishes "80:80"/"443:443", or give those ports an explicit 0.0.0.0: address.
- import_playbook: softure.vps_foundation.vps_bootstrap
vars:
vps_app_user: myproject
vps_app_group: myproject
vps_timezone: "Europe/Warsaw"
vps_locale: "en_US.UTF-8"
vps_github_actions_ssh_required: true # prompts for GitHub Actions SSH key- import_playbook: softure.vps_foundation.vps_deploy_infrastructure
vars:
services_network: myproject-network
services_postgres_enabled: true
services_pgadmin_enabled: true
postgres_db: myproject_db
postgres_user: myproject
postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') }}"
postgres_init_script_path: "{{ playbook_dir }}/files/init.sql"
rabbitmq_config_path: "{{ playbook_dir }}/files/rabbitmq.conf"
services_mssql_enabled: true
mssql_sa_password: "{{ lookup('env', 'MSSQL_SA_PASSWORD') }}"
services_seq_enabled: true
services_typesense_enabled: true
services_portainer_enabled: false
services_supertokens_enabled: true
services_cronicle_enabled: true
cronicle_secret_key: "{{ lookup('env', 'CRONICLE_SECRET_KEY') }}"Three roles for a server that hosts several apps, each deployed by its own release pipeline. None of them names an app: every shared name is a variable with a generic default.
| Role | What it owns | Main variables (defaults) |
|---|---|---|
traefik_edge |
/srv/softure-edge/docker-compose.yml (compose project softure-edge) and the network softure-edge. Apps join that network as external and route with labels (traefik.enable=true, tls.certresolver=letsencrypt); exposedByDefault is off. HTTP redirects to HTTPS, --ping health check, JSON access log without client addresses |
traefik_edge_name, traefik_edge_network, traefik_edge_cert_resolver (letsencrypt), traefik_edge_acme_email (required), traefik_edge_acme_challenge (http, or dns with traefik_edge_cloudflare_dns_token), traefik_edge_trusted_ips |
cloudflare_origin |
/usr/local/sbin/softure-cloudflare-only with its .service (runs on every Docker start) and .path (re-runs when the ranges change), /usr/local/sbin/softure-cloudflare-only-refresh with its daily .timer, the ranges file (seeded from the role's list) |
cloudflare_origin_name, cloudflare_origin_ranges_file (/srv/softure-edge/cloudflare-ips.txt), cloudflare_origin_ports ([80, 443]), cloudflare_origin_interface (default route's), cloudflare_origin_refresh_calendar (daily) |
deploy_user |
the user deploy (no sudo, in docker) and one line per app in its authorized_keys: command="/srv/<app>/deploy.sh",restrict <key> <app>-release. A line is added or replaced by its comment, never duplicated; other lines stay; state: absent removes it |
deploy_user_name, deploy_user_apps: [{ name, key?, command?, state? }], deploy_user_app_root (/srv) |
UFW does not filter traffic to published container ports (Docker's DNAT runs before UFW's chains), which is why
cloudflare_origin works in DOCKER-USER. The deploy user must also be in vps_ssh_allowed_users, or sshd's
AllowUsers turns it away. deploy_user runs after docker_engine (it joins the docker group).
- import_playbook: softure.vps_foundation.vps_bootstrap
vars:
vps_app_user: admin
vps_ssh_allowed_users: [admin, deploy]
vps_github_actions_ssh_required: false
- name: Shared app edge
hosts: all
become: true
roles:
- role: softure.vps_foundation.deploy_user
vars:
deploy_user_apps:
- { name: myapp, key: "ssh-ed25519 AAAA... any-comment" }
- role: softure.vps_foundation.traefik_edge
vars:
traefik_edge_acme_email: ops@example.com
- role: softure.vps_foundation.cloudflare_origin# playbooks/setup.yml
---
- name: "Phase 1: Bootstrap VPS"
ansible.builtin.import_playbook: softure.vps_foundation.vps_bootstrap
vars:
vps_app_user: deploy
vps_app_group: deploy
vps_timezone: "Europe/Warsaw"
tags: [bootstrap]
- name: "Phase 2: Deploy Infrastructure"
ansible.builtin.import_playbook: softure.vps_foundation.vps_deploy_infrastructure
vars:
services_network: app-network
services_postgres_enabled: true
services_pgadmin_enabled: true
services_caddy_enabled: true
services_seq_enabled: true
tags: [infrastructure]Run from the root of your project (where inventory/ and playbooks/ directories are located):
# Install collection and dependencies
ansible-galaxy collection install -r requirements.yml
# Full setup (bootstrap + infrastructure)
ansible-playbook -i inventory/hosts.yml playbooks/setup.yml
# Bootstrap only
ansible-playbook -i inventory/hosts.yml playbooks/setup.yml --tags bootstrap
# Infrastructure only
ansible-playbook -i inventory/hosts.yml playbooks/setup.yml --tags infrastructure
# Single service
ansible-playbook -i inventory/hosts.yml playbooks/setup.yml --tags postgresTo harden a fresh server and install Docker without creating an inventory — services deployed separately — use the wrapper script:
# Install the collection first
ansible-galaxy collection install . --force
./scripts/bootstrap.sh \
--host 203.0.113.10 \
--user deploy \
--pubkey ~/.ssh/id_ed25519.pubThe script verifies key-based SSH access before running, then passes everything through --extra-vars — nothing is written to disk. Options: --ssh-user, --ssh-port, --timezone, --locale, --github-actions, --check. Arguments after -- are forwarded to ansible-playbook.
The public key must already be authorized on the server (ssh-copy-id first) — Ansible cannot log in with a password without sshpass.
See the example/ directory for a complete working setup including inventory, group vars, playbooks, and sample config files.
- Ansible >= 2.14
- Ubuntu 22.04+ (jammy, noble)
- Collections:
community.docker,community.general,ansible.posix
- Bootstrap playbook validates Ubuntu 22.04+ and waits for system readiness before proceeding
- After running
vps_bootstrap, login using thevps_app_useraccount instead of the defaultubuntuorrootfrom your provider
MIT