diff --git a/.gitignore b/.gitignore index 6e46208..70073e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ # Pulumi +azure/venv/ venv/ *.pyc __pycache__/ # Pulumi state (if using local backend) +azure/.pulumi/ .pulumi/ # Secrets @@ -16,5 +18,5 @@ __pycache__/ .DS_Store # Local configurations (DO NOT COMMIT) -Pulumi.dev.yaml -ansible/inventory.yml +azure/Pulumi.dev.yaml +azure/ansible/inventory.yml diff --git a/README.md b/README.md index 5691555..919b6fc 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ cd cloud-vps 2. **Choose Your Cloud Provider:** This blueprint is natively written for **Microsoft Azure**, but the Ansible logic is completely cloud-agnostic. Ensure you have the Azure CLI installed and have run `az login` to authenticate. -3. **Configure the Blueprint:** Copy the example configuration files and modify `Pulumi.dev.yaml` with your preferred region, VM size, and local SSH key path: +3. **Configure the Blueprint:** Navigate into the `azure/` directory, copy the example configuration files, and modify `Pulumi.dev.yaml` with your preferred region, VM size, and local SSH key path: ```bash +cd azure cp Pulumi.dev.example.yaml Pulumi.dev.yaml cp ansible/inventory.example.yml ansible/inventory.yml ``` @@ -70,12 +71,15 @@ You can deploy the cloud infrastructure using the automated script or run the st **Option A: Automated Deployment** ```bash +cd azure chmod +x scripts/deploy.sh ./scripts/deploy.sh ``` **Option B: Manual Deployment** ```bash +cd azure + # 1. Provision cloud resources pulumi up --stack dev diff --git a/azure/ansible/ansible.cfg b/azure/ansible/ansible.cfg new file mode 100644 index 0000000..991fd54 --- /dev/null +++ b/azure/ansible/ansible.cfg @@ -0,0 +1,10 @@ +[defaults] +inventory = inventory.yml +remote_user = papyrus +private_key_file = ~/.ssh/id_ed25519 +host_key_checking = False +retry_files_enabled = False + +[privilege_escalation] +become = True +become_method = sudo diff --git a/azure/ansible/inventory.example.yml b/azure/ansible/inventory.example.yml new file mode 100644 index 0000000..aa87e38 --- /dev/null +++ b/azure/ansible/inventory.example.yml @@ -0,0 +1,5 @@ +all: + hosts: + vps: + ansible_host: + ansible_user: papyrus diff --git a/azure/ansible/playbook.yml b/azure/ansible/playbook.yml new file mode 100644 index 0000000..2db5524 --- /dev/null +++ b/azure/ansible/playbook.yml @@ -0,0 +1,11 @@ +--- +# Main playbook — configures a fresh VPS with Docker, security, and backup +- name: Configure VPS + hosts: all + become: true + + roles: + - base + - security + - docker + - backup diff --git a/azure/ansible/roles/backup/tasks/main.yml b/azure/ansible/roles/backup/tasks/main.yml new file mode 100644 index 0000000..be4538b --- /dev/null +++ b/azure/ansible/roles/backup/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# Restic backup setup — installs restic and deploys the backup script +- name: Install restic + apt: + name: restic + state: present + +- name: Create secrets directory + file: + path: /srv/secrets + state: directory + mode: "0700" + +- name: Deploy backup script + template: + src: backup.sh.j2 + dest: /usr/local/bin/backup.sh + mode: "0700" + +- name: Setup daily backup cron (3 AM) + cron: + name: "restic daily backup" + hour: "3" + minute: "0" + job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1" diff --git a/azure/ansible/roles/backup/templates/backup.sh.j2 b/azure/ansible/roles/backup/templates/backup.sh.j2 new file mode 100644 index 0000000..9295317 --- /dev/null +++ b/azure/ansible/roles/backup/templates/backup.sh.j2 @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Managed by Ansible — do not edit manually + +export B2_ACCOUNT_ID="{{ b2_account_id | default('YOUR_KEY_ID') }}" +export B2_ACCOUNT_KEY="{{ b2_account_key | default('YOUR_APPLICATION_KEY') }}" +export RESTIC_PASSWORD="{{ restic_password | default('YOUR_BACKUP_PASSWORD') }}" + +REPO="b2:{{ b2_bucket | default('your-bucket') }}:/backup" + +restic -r "$REPO" backup \ + --exclude /home/papyrus/apps/navidrome/music \ + /var/lib/docker/volumes \ + /srv/secrets \ + /home/papyrus/apps + +restic -r "$REPO" forget --keep-last 7 --keep-weekly 4 --prune diff --git a/azure/ansible/roles/base/tasks/main.yml b/azure/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..de1df7a --- /dev/null +++ b/azure/ansible/roles/base/tasks/main.yml @@ -0,0 +1,24 @@ +--- +# Base packages and system updates +- name: Update apt cache + apt: + update_cache: true + cache_valid_time: 3600 + +- name: Upgrade all packages + apt: + upgrade: dist + +- name: Install essential packages + apt: + name: + - curl + - git + - unzip + - vim + - htop + - wget + - ca-certificates + - gnupg + - lsb-release + state: present diff --git a/azure/ansible/roles/docker/tasks/main.yml b/azure/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..414ff1e --- /dev/null +++ b/azure/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,47 @@ +--- +# Docker CE + Docker Compose v2 plugin +- name: Remove old Docker packages + apt: + name: + - docker + - docker-engine + - docker.io + - containerd + - runc + state: absent + +- name: Add Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + +- name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu + {{ ansible_distribution_release }} stable + state: present + +- name: Install Docker CE + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: true + +- name: Enable and start Docker + systemd: + name: docker + enabled: true + state: started + +- name: Add admin user to docker group + user: + name: "{{ ansible_user }}" + groups: docker + append: true diff --git a/azure/ansible/roles/security/tasks/main.yml b/azure/ansible/roles/security/tasks/main.yml new file mode 100644 index 0000000..5acc9a0 --- /dev/null +++ b/azure/ansible/roles/security/tasks/main.yml @@ -0,0 +1,46 @@ +--- +# Security hardening — UFW firewall + fail2ban +- name: Install security packages + apt: + name: + - ufw + - fail2ban + state: present + +- name: Set UFW default deny incoming + community.general.ufw: + direction: incoming + default: deny + +- name: Set UFW default allow outgoing + community.general.ufw: + direction: outgoing + default: allow + +- name: Allow SSH through UFW + community.general.ufw: + rule: allow + port: "22" + proto: tcp + +- name: Allow HTTP through UFW + community.general.ufw: + rule: allow + port: "80" + proto: tcp + +- name: Allow HTTPS through UFW + community.general.ufw: + rule: allow + port: "443" + proto: tcp + +- name: Enable UFW + community.general.ufw: + state: enabled + +- name: Enable and start fail2ban + systemd: + name: fail2ban + enabled: true + state: started diff --git a/azure/pulumi/Pulumi.dev.example.yaml b/azure/pulumi/Pulumi.dev.example.yaml new file mode 100644 index 0000000..b07ab89 --- /dev/null +++ b/azure/pulumi/Pulumi.dev.example.yaml @@ -0,0 +1,5 @@ +config: + azure-native:location: southeastasia + azure-vps:vm_size: Standard_B1ms + azure-vps:admin_username: papyrus + azure-vps:ssh_public_key_path: ~/.ssh/id_ed25519.pub diff --git a/azure/pulumi/Pulumi.yaml b/azure/pulumi/Pulumi.yaml new file mode 100644 index 0000000..664f368 --- /dev/null +++ b/azure/pulumi/Pulumi.yaml @@ -0,0 +1,6 @@ +name: azure-vps +runtime: + name: python + options: + virtualenv: venv +description: Personal VPS on Azure with Pulumi + Ansible diff --git a/azure/pulumi/__main__.py b/azure/pulumi/__main__.py new file mode 100644 index 0000000..476880f --- /dev/null +++ b/azure/pulumi/__main__.py @@ -0,0 +1,172 @@ +""" +Azure VPS — Pulumi infrastructure +Creates a single Linux VM with Docker-ready networking. +""" + +import pulumi +from pulumi import Config, Output, export +from pulumi_azure_native import resources, network, compute +from pathlib import Path + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +config = Config("azure-vps") + +VM_SIZE = config.get("vm_size") or "Standard_B1ms" +ADMIN_USERNAME = config.get("admin_username") or "papyrus" +SSH_KEY_PATH = config.get("ssh_public_key_path") or "~/.ssh/id_ed25519.pub" + +ssh_public_key = Path(SSH_KEY_PATH).expanduser().read_text().strip() + +# --------------------------------------------------------------------------- +# Resource Group +# --------------------------------------------------------------------------- +rg = resources.ResourceGroup( + "rg", + resource_group_name="papyrus-vm-rg", + tags={"environment": "dev", "managed_by": "pulumi"}, +) + +# --------------------------------------------------------------------------- +# Networking — VNet, Subnet, Public IP, NSG, NIC +# --------------------------------------------------------------------------- +vnet = network.VirtualNetwork( + "vnet", + virtual_network_name="papyrus-vnet", + resource_group_name=rg.name, + address_space=network.AddressSpaceArgs( + address_prefixes=["10.0.0.0/16"], + ), +) + +subnet = network.Subnet( + "subnet", + subnet_name="papyrus-subnet", + resource_group_name=rg.name, + virtual_network_name=vnet.name, + address_prefix="10.0.1.0/24", +) + +public_ip = network.PublicIPAddress( + "pip", + public_ip_address_name="papyrus-pip", + resource_group_name=rg.name, + public_ip_allocation_method=network.IPAllocationMethod.STATIC, + sku=network.PublicIPAddressSkuArgs( + name=network.PublicIPAddressSkuName.STANDARD, + ), +) + +nsg = network.NetworkSecurityGroup( + "nsg", + network_security_group_name="papyrus-nsg", + resource_group_name=rg.name, + security_rules=[ + network.SecurityRuleArgs( + name="SSH", + priority=1000, + direction=network.SecurityRuleDirection.INBOUND, + access=network.SecurityRuleAccess.ALLOW, + protocol=network.SecurityRuleProtocol.TCP, + source_port_range="*", + destination_port_range="22", + source_address_prefix="*", + destination_address_prefix="*", + ), + network.SecurityRuleArgs( + name="HTTP", + priority=1010, + direction=network.SecurityRuleDirection.INBOUND, + access=network.SecurityRuleAccess.ALLOW, + protocol=network.SecurityRuleProtocol.TCP, + source_port_range="*", + destination_port_range="80", + source_address_prefix="*", + destination_address_prefix="*", + ), + network.SecurityRuleArgs( + name="HTTPS", + priority=1020, + direction=network.SecurityRuleDirection.INBOUND, + access=network.SecurityRuleAccess.ALLOW, + protocol=network.SecurityRuleProtocol.TCP, + source_port_range="*", + destination_port_range="443", + source_address_prefix="*", + destination_address_prefix="*", + ), + ], +) + +nic = network.NetworkInterface( + "nic", + network_interface_name="papyrus-nic", + resource_group_name=rg.name, + ip_configurations=[ + network.NetworkInterfaceIPConfigurationArgs( + name="papyrus-ipconfig", + subnet=network.SubnetArgs(id=subnet.id), + public_ip_address=network.PublicIPAddressArgs(id=public_ip.id), + private_ip_allocation_method=network.IPAllocationMethod.DYNAMIC, + ), + ], + network_security_group=network.NetworkSecurityGroupArgs(id=nsg.id), +) + +# --------------------------------------------------------------------------- +# Virtual Machine — Ubuntu 24.04 LTS +# --------------------------------------------------------------------------- +vm = compute.VirtualMachine( + "vm", + vm_name="papyrus-vm", + resource_group_name=rg.name, + hardware_profile=compute.HardwareProfileArgs( + vm_size=VM_SIZE, + ), + os_profile=compute.OSProfileArgs( + computer_name="papyrus-vm", + admin_username=ADMIN_USERNAME, + linux_configuration=compute.LinuxConfigurationArgs( + disable_password_authentication=True, + ssh=compute.SshConfigurationArgs( + public_keys=[ + compute.SshPublicKeyArgs( + path=f"/home/{ADMIN_USERNAME}/.ssh/authorized_keys", + key_data=ssh_public_key, + ), + ], + ), + ), + ), + network_profile=compute.NetworkProfileArgs( + network_interfaces=[ + compute.NetworkInterfaceReferenceArgs( + id=nic.id, + primary=True, + ), + ], + ), + storage_profile=compute.StorageProfileArgs( + os_disk=compute.OSDiskArgs( + create_option=compute.DiskCreateOptionTypes.FROM_IMAGE, + managed_disk=compute.ManagedDiskParametersArgs( + storage_account_type=compute.StorageAccountTypes.STANDARD_LRS, + ), + ), + image_reference=compute.ImageReferenceArgs( + publisher="Canonical", + offer="ubuntu-24_04-lts", + sku="server", + version="latest", + ), + ), + tags={"environment": "dev", "managed_by": "pulumi"}, +) + +# --------------------------------------------------------------------------- +# Outputs +# --------------------------------------------------------------------------- +export("vm_public_ip", public_ip.ip_address) +export("admin_username", ADMIN_USERNAME) +export("ssh_command", Output.concat("ssh ", ADMIN_USERNAME, "@", public_ip.ip_address)) diff --git a/azure/scripts/deploy.sh b/azure/scripts/deploy.sh new file mode 100755 index 0000000..0ed3eb0 --- /dev/null +++ b/azure/scripts/deploy.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# One-command deploy: pulumi up → ansible provision +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Step 1: Pulumi — creating Azure infrastructure ===" +cd "$PROJECT_DIR" +pulumi up --yes --stack dev + +# grab outputs +VM_IP=$(pulumi stack output vm_public_ip --stack dev) +ADMIN_USER=$(pulumi stack output admin_username --stack dev) + +echo "" +echo "VM is up at: $VM_IP" +echo "User: $ADMIN_USER" + +echo "" +echo "=== Step 2: Generating Ansible inventory ===" +if [ ! -f "$PROJECT_DIR/ansible/inventory.yml" ]; then + cat > "$PROJECT_DIR/ansible/inventory.yml" </dev/null; then + echo "SSH is ready!" + break + fi + echo " waiting for SSH... (attempt $i/$MAX_RETRIES)" + sleep 10 +done + +echo "" +echo "=== Step 4: Ansible — configuring the VPS ===" +cd "$PROJECT_DIR/ansible" +ansible-playbook playbook.yml + +echo "" +echo "=== Done! ===" +echo "SSH in: ssh ${ADMIN_USER}@${VM_IP}" +echo "Next: clone your repos and docker compose up"