feat: add deployment infrastructure with Terraform, Ansible, and GitHub Actions

This commit is contained in:
NetworkCats
2026-03-05 21:12:50 +08:00
parent e576ecfefd
commit 5ec9a53844
22 changed files with 1012 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
name: Deploy
on:
push:
tags:
- "v*"
env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
# ---- CI checks ----
check:
name: Check
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.93.1"
components: rustfmt, clippy
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Download MMDB database
run: |
mkdir -p data
curl -fsSL -o data/Merged-IP.mmdb \
"https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb"
- name: Check formatting
run: cargo fmt --check
- name: Run Clippy
run: cargo clippy -- -D warnings
- name: Run tests
run: cargo test
# ---- Build and push Docker image ----
build:
name: Build Image
runs-on: ubuntu-latest
needs: check
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
image_full: ${{ steps.full_image.outputs.image }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Set full image reference
id: full_image
run: |
TAG="${GITHUB_REF_NAME#v}"
echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_OUTPUT"
# ---- Deploy to VPS ----
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v4
# ---- Terraform: provision infrastructure ----
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- name: Terraform init
working-directory: deploy/terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }}
run: |
terraform init \
-backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \
-backend-config="endpoints={s3=\"${{ secrets.TF_STATE_ENDPOINT }}\"}"
- name: Terraform apply
working-directory: deploy/terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }}
run: |
terraform apply -auto-approve \
-var="vultr_api_key=${{ secrets.VULTR_API_KEY }}" \
-var="ssh_public_key=${{ secrets.SSH_PUBLIC_KEY }}"
- name: Extract VPS IP (masked)
id: vps
working-directory: deploy/terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }}
run: |
VPS_IP=$(terraform output -raw instance_ip)
echo "::add-mask::${VPS_IP}"
echo "ip=${VPS_IP}" >> "$GITHUB_OUTPUT"
# ---- SSH setup ----
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "${{ steps.vps.outputs.ip }}" >> ~/.ssh/known_hosts 2>/dev/null || true
# ---- Detect first deployment ----
- name: Detect first deployment
id: detect
run: |
set +e
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
-i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \
"test -f /opt/rustyip/.active_color"
if [ $? -ne 0 ]; then
echo "first_deploy=true" >> "$GITHUB_OUTPUT"
else
echo "first_deploy=false" >> "$GITHUB_OUTPUT"
fi
# ---- Ansible: configure VPS and deploy ----
- name: Install Ansible
run: pip install ansible
- name: Generate Ansible inventory
run: |
cat > deploy/ansible/inventory.yml <<EOF
all:
hosts:
vps:
ansible_host: "${{ steps.vps.outputs.ip }}"
ansible_user: root
ansible_ssh_private_key_file: ~/.ssh/deploy_key
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"
EOF
- name: Run Ansible playbook
working-directory: deploy/ansible
env:
APP_IMAGE: ${{ needs.build.outputs.image_full }}
SITE_DOMAIN: ${{ secrets.SITE_DOMAIN }}
DB_UPDATE_URL: ${{ secrets.DB_UPDATE_URL }}
ORIGIN_CERT: ${{ secrets.ORIGIN_CERT }}
ORIGIN_KEY: ${{ secrets.ORIGIN_KEY }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
FIRST_DEPLOY: ${{ steps.detect.outputs.first_deploy }}
run: |
ansible-playbook -i inventory.yml playbook.yml
# ---- Verify deployment ----
- name: Verify deployment health
run: |
DEPLOY_COLOR=$(ssh -i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \
"cat /opt/rustyip/.active_color")
for i in $(seq 1 15); do
status=$(ssh -i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \
"docker inspect --format='{{ '{{' }}.State.Health.Status{{ '}}' }}' rustyip-app-${DEPLOY_COLOR}" 2>/dev/null)
if [ "$status" = "healthy" ]; then
echo "Deployment verified: app-${DEPLOY_COLOR} is healthy"
exit 0
fi
echo "Waiting for health check... (attempt $i/15)"
sleep 4
done
echo "Health check failed after 60 seconds"
exit 1
# ---- Cleanup ----
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/deploy_key
+14
View File
@@ -27,6 +27,20 @@ data/
.env
haproxy/certs/
# Terraform
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
.terraform.lock.hcl
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ansible
deploy/ansible/inventory.yml
*.retry
# Added by cargo
+22
View File
@@ -0,0 +1,22 @@
---
- name: Configure RustyIP VPS
hosts: vps
become: true
gather_facts: true
vars:
app_image: "{{ lookup('env', 'APP_IMAGE') }}"
site_domain: "{{ lookup('env', 'SITE_DOMAIN') }}"
db_update_url: "{{ lookup('env', 'DB_UPDATE_URL') }}"
origin_cert: "{{ lookup('env', 'ORIGIN_CERT') }}"
origin_key: "{{ lookup('env', 'ORIGIN_KEY') }}"
ghcr_token: "{{ lookup('env', 'GHCR_TOKEN') }}"
ghcr_user: "{{ lookup('env', 'GHCR_USER') }}"
first_deploy: "{{ lookup('env', 'FIRST_DEPLOY') | default('false', true) }}"
roles:
- role: base
when: first_deploy | bool
- role: docker
when: first_deploy | bool
- role: app
@@ -0,0 +1,5 @@
---
- name: Reload haproxy
ansible.builtin.shell: |
docker compose -f /opt/rustyip/docker-compose.yml kill -s HUP haproxy
failed_when: false
+194
View File
@@ -0,0 +1,194 @@
---
- name: Create application directory
ansible.builtin.file:
path: /opt/rustyip
state: directory
owner: root
group: root
mode: "0755"
- name: Create HAProxy directory
ansible.builtin.file:
path: /opt/rustyip/haproxy
state: directory
owner: root
group: root
mode: "0755"
- name: Create TLS certificate directory
ansible.builtin.file:
path: /opt/rustyip/haproxy/certs
state: directory
owner: root
group: root
mode: "0700"
- name: Deploy TLS origin certificate
ansible.builtin.copy:
content: "{{ origin_cert }}\n{{ origin_key }}"
dest: /opt/rustyip/haproxy/certs/origin.pem
owner: root
group: root
mode: "0600"
notify: Reload haproxy
- name: Deploy environment file
ansible.builtin.template:
src: env.j2
dest: /opt/rustyip/.env
owner: root
group: root
mode: "0600"
- name: Log in to GHCR
ansible.builtin.shell: |
echo "{{ ghcr_token }}" | docker login ghcr.io -u "{{ ghcr_user }}" --password-stdin
no_log: true
changed_when: true
- name: Pull application image
ansible.builtin.shell: |
docker pull "{{ app_image }}"
changed_when: true
# --- Determine deployment state ---
- name: Check for existing active color state file
ansible.builtin.stat:
path: /opt/rustyip/.active_color
register: active_color_stat
- name: Read active color from state file
ansible.builtin.slurp:
src: /opt/rustyip/.active_color
register: active_color_file
when: active_color_stat.stat.exists
- name: Set color facts for existing deployment
ansible.builtin.set_fact:
current_active: "{{ (active_color_file.content | b64decode | trim) }}"
deploy_target: "{{ 'green' if (active_color_file.content | b64decode | trim) == 'blue' else 'blue' }}"
is_first_deploy: false
when: active_color_stat.stat.exists
- name: Set color facts for first deployment
ansible.builtin.set_fact:
current_active: ""
deploy_target: "blue"
is_first_deploy: true
when: not active_color_stat.stat.exists
# --- Deploy docker-compose and HAProxy config ---
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: /opt/rustyip/docker-compose.yml
owner: root
group: root
mode: "0644"
- name: Deploy HAProxy configuration (target color active)
ansible.builtin.template:
src: haproxy.cfg.j2
dest: /opt/rustyip/haproxy/haproxy.cfg
owner: root
group: root
mode: "0644"
vars:
active_color: "{{ deploy_target }}"
# --- First Deployment ---
- name: Start target app container
ansible.builtin.shell: |
docker compose -f /opt/rustyip/docker-compose.yml up -d "app-{{ deploy_target }}"
changed_when: true
when: is_first_deploy
- name: Wait for target container health check (first deploy)
ansible.builtin.shell: |
for i in $(seq 1 30); do
status=$(docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' "rustyip-app-{{ deploy_target }}" 2>/dev/null)
if [ "$status" = "healthy" ]; then
echo "healthy"
exit 0
fi
sleep 2
done
echo "timeout"
exit 1
changed_when: false
when: is_first_deploy
- name: Start HAProxy (first deploy)
ansible.builtin.shell: |
docker compose -f /opt/rustyip/docker-compose.yml up -d haproxy
changed_when: true
when: is_first_deploy
- name: Write active color state file (first deploy)
ansible.builtin.copy:
content: "{{ deploy_target }}"
dest: /opt/rustyip/.active_color
owner: root
group: root
mode: "0644"
when: is_first_deploy
# --- Blue-Green Deployment ---
- name: Start standby container with new image
ansible.builtin.shell: |
docker compose -f /opt/rustyip/docker-compose.yml up -d "app-{{ deploy_target }}"
changed_when: true
when: not is_first_deploy
- name: Wait for standby container health check
ansible.builtin.shell: |
for i in $(seq 1 30); do
status=$(docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' "rustyip-app-{{ deploy_target }}" 2>/dev/null)
if [ "$status" = "healthy" ]; then
echo "healthy"
exit 0
fi
sleep 2
done
echo "timeout"
exit 1
register: health_result
changed_when: false
when: not is_first_deploy
- name: Reload HAProxy to switch to new backend
ansible.builtin.shell: |
docker compose -f /opt/rustyip/docker-compose.yml up -d haproxy
changed_when: true
when: not is_first_deploy
- name: Wait for HAProxy to settle
ansible.builtin.pause:
seconds: 5
when: not is_first_deploy
- name: Stop old active container
ansible.builtin.shell: |
docker compose -f /opt/rustyip/docker-compose.yml stop "app-{{ current_active }}"
docker compose -f /opt/rustyip/docker-compose.yml rm -f "app-{{ current_active }}"
changed_when: true
when: not is_first_deploy
- name: Update active color state file
ansible.builtin.copy:
content: "{{ deploy_target }}"
dest: /opt/rustyip/.active_color
owner: root
group: root
mode: "0644"
when: not is_first_deploy
- name: Clean up unused Docker images
ansible.builtin.shell: |
docker image prune -af --filter "until=1h"
changed_when: true
failed_when: false
@@ -0,0 +1,66 @@
services:
haproxy:
image: haproxy:3.3.4-alpine3.23
container_name: rustyip-haproxy
ports:
- "443:443"
volumes:
- ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- ./haproxy/certs:/etc/haproxy/certs:ro
restart: unless-stopped
ulimits:
nofile:
soft: 1048576
hard: 1048576
sysctls:
net.core.somaxconn: "65535"
net.ipv4.tcp_tw_reuse: "1"
networks:
- internal
app-blue:
image: {{ app_image }}
container_name: rustyip-app-blue
env_file: .env
volumes:
- db_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"]
interval: 5s
timeout: 3s
retries: 3
start_period: 15s
restart: unless-stopped
ulimits:
nofile:
soft: 1048576
hard: 1048576
networks:
- internal
app-green:
image: {{ app_image }}
container_name: rustyip-app-green
env_file: .env
volumes:
- db_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"]
interval: 5s
timeout: 3s
retries: 3
start_period: 15s
restart: unless-stopped
ulimits:
nofile:
soft: 1048576
hard: 1048576
networks:
- internal
volumes:
db_data:
networks:
internal:
driver: bridge
@@ -0,0 +1,5 @@
LISTEN_ADDR=0.0.0.0:3000
DB_PATH=/data/Merged-IP.mmdb
DB_UPDATE_URL={{ db_update_url }}
DB_UPDATE_INTERVAL_HOURS=24
SITE_DOMAIN={{ site_domain }}
@@ -0,0 +1,77 @@
global
log stderr local0 err
maxconn 100000
# Performance tuning for high-throughput
tune.bufsize 16384
tune.maxrewrite 1024
tune.http.maxhdr 128
tune.comp.maxlevel 1
tune.ssl.default-dh-param 2048
# TLS configuration optimized for Cloudflare-to-origin connections
ssl-default-bind-curves X25519:prime256v1:secp384r1
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers ssl-min-ver TLSv1.2 no-tls-tickets
defaults
log global
mode http
option httplog
option dontlognormal
option http-keep-alive
maxconn 100000
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 60s
timeout queue 30s
frontend https_in
bind *:443 ssl crt /etc/haproxy/certs/origin.pem alpn h2,http/1.1 strict-sni
# Gzip compression
compression algo gzip
compression type text/html text/plain application/json text/css
# Access to port 443 is already restricted to Cloudflare IPs at the firewall level
# All requests reaching HAProxy come from Cloudflare; no additional ACL validation is required.
# HAProxy's own forwardfor must be disabled, otherwise it will insert Cloudflare node IPs
no option forwardfor
# Overwrite standard headers directly with the real IP forcibly added by Cloudflare
http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)]
http-request set-header X-Real-IP %[req.hdr(CF-Connecting-IP)]
# Rate limiting: tiered based on global request rate
# Track per-IP request rate (per minute)
stick-table type string len 45 size 500k expire 1m store http_req_rate(1m)
http-request track-sc0 req.hdr(CF-Connecting-IP)
# Track global request rate (per second)
http-request track-sc1 str(global) table global_rate_tracking
# Tier 3: global >= 200 RPS -> limit each IP to 5 RPM
http-request deny deny_status 429 if { sc_http_req_rate(1,global_rate_tracking) ge 200 } { sc_http_req_rate(0) gt 5 }
# Tier 2: global >= 100 RPS -> limit each IP to 10 RPM
http-request deny deny_status 429 if { sc_http_req_rate(1,global_rate_tracking) ge 100 } { sc_http_req_rate(0) gt 10 }
# Tier 1: default -> limit each IP to 30 RPM
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 30 }
# Block /health from external access
acl is_health path /health
http-request deny deny_status 403 if is_health
default_backend app
backend global_rate_tracking
stick-table type string len 6 size 1 expire 10m store http_req_rate(1s)
backend app
balance roundrobin
option httpchk GET /health
http-check expect status 200
# Blue-green backends: only the active color is enabled
server blue app-blue:3000 check inter 2s fall 3 rise 2 {{ 'disabled' if active_color == 'green' else '' }}
server green app-green:3000 check inter 2s fall 3 rise 2 {{ 'disabled' if active_color == 'blue' else '' }}
@@ -0,0 +1,9 @@
---
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
- name: Reload sysctl
ansible.builtin.command: sysctl --system
changed_when: true
+106
View File
@@ -0,0 +1,106 @@
---
- name: Wait for cloud-init to finish
ansible.builtin.command: cloud-init status --wait
changed_when: false
failed_when: false
- name: Update apt cache and upgrade all packages
ansible.builtin.apt:
update_cache: true
upgrade: dist
cache_valid_time: 3600
- name: Install essential packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- curl
- wget
- htop
- unattended-upgrades
- apt-transport-https
- ca-certificates
- gnupg
- lsb-release
- iptables
state: present
# --- UFW Configuration ---
- name: Set UFW default policy - deny incoming
community.general.ufw:
direction: incoming
default: deny
- name: Set UFW default policy - allow outgoing
community.general.ufw:
direction: outgoing
default: allow
- name: Allow SSH (port 22) through UFW
community.general.ufw:
rule: allow
port: "22"
proto: tcp
comment: "SSH"
- name: Allow HTTPS (port 443) through UFW
community.general.ufw:
rule: allow
port: "443"
proto: tcp
comment: "HTTPS"
- name: Enable UFW
community.general.ufw:
state: enabled
# --- fail2ban Configuration ---
- name: Deploy fail2ban SSH jail configuration
ansible.builtin.template:
src: fail2ban-sshd.conf.j2
dest: /etc/fail2ban/jail.d/sshd.conf
owner: root
group: root
mode: "0644"
notify: Restart fail2ban
- name: Ensure fail2ban is enabled and running
ansible.builtin.systemd:
name: fail2ban
enabled: true
state: started
# --- Kernel Tuning ---
- name: Deploy sysctl tuning configuration
ansible.builtin.template:
src: sysctl.conf.j2
dest: /etc/sysctl.d/99-rustyip.conf
owner: root
group: root
mode: "0644"
notify: Reload sysctl
- name: Deploy system-wide file descriptor limits
ansible.builtin.template:
src: limits.conf.j2
dest: /etc/security/limits.d/99-rustyip.conf
owner: root
group: root
mode: "0644"
# --- Automatic Security Updates ---
- name: Enable unattended-upgrades for security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
owner: root
group: root
mode: "0644"
@@ -0,0 +1,14 @@
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 5
findtime = 600
bantime = 3600
# Use iptables directly instead of UFW
banaction = iptables-multiport
banaction_allports = iptables-allports
logpath = /var/log/auth.log
backend = auto
@@ -0,0 +1,5 @@
# System-wide file descriptor limits for high-throughput service
* soft nofile 1048576
* hard nofile 1048576
root soft nofile 1048576
root hard nofile 1048576
@@ -0,0 +1,59 @@
# Network performance tuning for high-throughput web service
# Socket buffer sizes
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
# TCP buffer auto-tuning
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# Connection backlog
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_max_syn_backlog = 65535
# TCP connection reuse and timeout
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
# TCP keepalive (detect dead connections faster)
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
# Ephemeral port range
net.ipv4.ip_local_port_range = 1024 65535
# TCP Fast Open (client + server)
net.ipv4.tcp_fastopen = 3
# Disable slow start after idle
net.ipv4.tcp_slow_start_after_idle = 0
# Enable TCP window scaling
net.ipv4.tcp_window_scaling = 1
# Enable TCP timestamps
net.ipv4.tcp_timestamps = 1
# Enable selective acknowledgements
net.ipv4.tcp_sack = 1
# SYN flood protection
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_tw_buckets = 2000000
# File descriptor limits
fs.file-max = 2097152
fs.nr_open = 2097152
# VM tuning
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
# Connection tracking (for iptables/fail2ban)
net.netfilter.nf_conntrack_max = 262144
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart docker
ansible.builtin.systemd:
name: docker
state: restarted
@@ -0,0 +1,69 @@
---
- name: Create Docker apt keyring directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Add Docker GPG key
ansible.builtin.shell: |
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
args:
creates: /etc/apt/keyrings/docker.gpg
- name: Set permissions on Docker GPG key
ansible.builtin.file:
path: /etc/apt/keyrings/docker.gpg
mode: "0644"
- name: Add Docker apt repository
ansible.builtin.shell: |
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list
args:
creates: /etc/apt/sources.list.d/docker.list
- name: Install Docker CE packages
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Deploy Docker daemon configuration
ansible.builtin.template:
src: daemon.json.j2
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
notify: Restart docker
- name: Ensure Docker is enabled and running
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Configure Docker systemd override for unlimited file descriptors
ansible.builtin.file:
path: /etc/systemd/system/docker.service.d
state: directory
mode: "0755"
- name: Deploy Docker systemd override
ansible.builtin.copy:
dest: /etc/systemd/system/docker.service.d/override.conf
content: |
[Service]
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Restart docker
@@ -0,0 +1,21 @@
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true,
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 1048576,
"Soft": 1048576
},
"nproc": {
"Name": "nproc",
"Hard": 65535,
"Soft": 65535
}
},
"default-shm-size": "256M"
}
+26
View File
@@ -0,0 +1,26 @@
resource "vultr_firewall_group" "rustyip" {
description = "${var.instance_label} firewall"
}
# Allow SSH from anywhere (IPv4)
resource "vultr_firewall_rule" "ssh_v4" {
firewall_group_id = vultr_firewall_group.rustyip.id
protocol = "tcp"
ip_type = "v4"
subnet = "0.0.0.0"
subnet_size = 0
port = "22"
notes = "Allow SSH from anywhere"
}
# Allow HTTPS from Cloudflare only (IPv4)
resource "vultr_firewall_rule" "https_cloudflare_v4" {
firewall_group_id = vultr_firewall_group.rustyip.id
protocol = "tcp"
ip_type = "v4"
subnet = "0.0.0.0"
subnet_size = 0
port = "443"
source = "cloudflare"
notes = "Allow HTTPS from Cloudflare IPv4"
}
+15
View File
@@ -0,0 +1,15 @@
resource "vultr_instance" "rustyip" {
plan = var.plan
region = var.region
os_id = var.os_id
label = var.instance_label
hostname = var.instance_label
enable_ipv6 = false
activation_email = false
backups = "disabled"
ddos_protection = false
firewall_group_id = vultr_firewall_group.rustyip.id
ssh_key_ids = [vultr_ssh_key.deploy.id]
tags = [var.instance_label]
}
+28
View File
@@ -0,0 +1,28 @@
terraform {
required_version = ">= 1.5"
required_providers {
vultr = {
source = "vultr/vultr"
version = "~> 2.0"
}
}
backend "s3" {
key = "rustyip/terraform.tfstate"
# Cloudflare R2 (S3-compatible)
# bucket, endpoint, access_key, secret_key are passed via -backend-config
region = "auto"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true
use_path_style = true
}
}
provider "vultr" {
api_key = var.vultr_api_key
}
+10
View File
@@ -0,0 +1,10 @@
output "instance_id" {
description = "Vultr instance ID"
value = vultr_instance.rustyip.id
}
output "instance_ip" {
description = "VPS public IPv4 address"
value = vultr_instance.rustyip.main_ip
sensitive = true
}
+4
View File
@@ -0,0 +1,4 @@
resource "vultr_ssh_key" "deploy" {
name = "${var.instance_label}-deploy"
ssh_key = var.ssh_public_key
}
+34
View File
@@ -0,0 +1,34 @@
variable "vultr_api_key" {
description = "Vultr API key"
type = string
sensitive = true
}
variable "ssh_public_key" {
description = "SSH public key for VPS access"
type = string
}
variable "region" {
description = "Vultr region ID"
type = string
default = "dfw"
}
variable "plan" {
description = "Vultr plan ID (High Frequency 2 vCPU / 2 GB)"
type = string
default = "vhf-2c-2gb"
}
variable "os_id" {
description = "Vultr OS ID (Debian 13 Trixie x64)"
type = number
default = 2625
}
variable "instance_label" {
description = "Label for the VPS instance"
type = string
default = "rustyip"
}