Author SHA1 Message Date
alessandrovitali fa568ada08 feat: add generated README 2026-08-18 15:25:25 +02:00
12 changed files with 107 additions and 139 deletions
-1
View File
@@ -6,7 +6,6 @@
# Secrets # Secrets
.env .env
.envrc
# Ansible # Ansible
ansible/inventory.ini ansible/inventory.ini
+86 -29
View File
@@ -2,15 +2,45 @@
Terraform + Ansible setup for a self-hosted [Dokploy](https://dokploy.com/) instance. Terraform + Ansible setup for a self-hosted [Dokploy](https://dokploy.com/) instance.
## Setup - **Terraform** provisions a Hetzner Cloud VPS, registers an SSH key, creates a Cloudflare DNS record, and writes the Ansible inventory.
- **Ansible** configures the host afterwards (base / users / security roles).
1. Create a Hetzner Cloud API key ## Repository layout
2. Create a Cloudflare DNS key with `Zone.DNS` edit permission
### Local setup ```
.
├── terraform/
│ ├── main.tf
│ ├── providers.tf
│ ├── variables.tf
│ ├── terraform.tfvars # non-secret config (committed)
│ ├── .env.example # template for required secrets (committed)
│ ├── .env # real secrets (gitignored — created locally)
│ └── .envrc # direnv loader (committed)
└── ansible/
├── playbook.yml
├── requirements.yml
└── group_vars/
```
Secrets live in `terraform/.env` and are injected as `TF_VAR_*` environment ## Secrets
variables into the local shell session:
Secrets are **never** committed and never written into `.tf` or `.tfvars` files.
They live only in `terraform/.env` and are injected as `TF_VAR_*` environment
variables, which Terraform maps to input variables automatically.
| Variable | Environment variable | Committed to git |
| --- | --- | --- |
| `hcloud_token` | `TF_VAR_hcloud_token` | No |
| `cloudflare_api_token` | `TF_VAR_cloudflare_api_token` | No |
| `domain_name` | `TF_VAR_domain_name` | No |
Everything else (server type, image, location, project name, SSH public key,
DNS record name, proxied flag) is non-secret and lives in `terraform/terraform.tfvars`.
### `.env` format
`terraform/.env`:
```env ```env
TF_VAR_hcloud_token=... TF_VAR_hcloud_token=...
@@ -18,53 +48,63 @@ TF_VAR_cloudflare_api_token=...
TF_VAR_domain_name=... TF_VAR_domain_name=...
``` ```
#### direnv ## Local setup
Setup direnv on the development machine and add `terraform/.envrc`: ### Prerequisites
```env - Terraform `>= 1.0`
- [direnv](https://direnv.net/) (recommended)
- A Hetzner Cloud API token
- A Cloudflare API token with `Zone.DNS` edit permission
### Steps
```sh
# 1. Create your local secrets file from the template, then fill in the values.
cp terraform/.env.example terraform/.env
# 2. Allow direnv to load it (evaluates terraform/.envrc).
cd terraform
direnv allow
# 3. Provision.
terraform init
terraform plan
terraform apply
```
`terraform/.envrc` loads the `.env` file:
```sh
dotenv_if_exists .env dotenv_if_exists .env
``` ```
Then load the variables into the shell: direnv loads the variables when you `cd` into `terraform/` and unloads them when
you leave, so secrets never linger in your shell.
```sh ### Without direnv
cd terraform
direnv allow
```
Run Terraform / Tofu manually:
```sh
tofu init
tofu plan
tofu apply
```
#### Without direnv
Load the file manually, scoped to a single command: Load the file manually, scoped to a single command:
```sh ```sh
cd terraform cd terraform
set -a; . ./.env; set +a set -a; . ./.env; set +a
tofu plan terraform plan
``` ```
Or use a wrapper that keeps the variables contained to the `tofu` subprocess: Or use a wrapper that keeps the variables contained to the `terraform` subprocess:
```sh ```sh
#!/bin/sh #!/bin/sh
set -a set -a
. ./.env . ./.env
set +a set +a
exec tofu "$@" exec terraform "$@"
``` ```
## CI/CD ## CI/CD
CI reads the same variables from the git forge's secret store — no `.env` file is needed. CI reads the same variables from its secret store — no `.env` file is needed.
Add the necessary variables
GitHub Actions example: GitHub Actions example:
@@ -73,4 +113,21 @@ env:
TF_VAR_hcloud_token: ${{ secrets.TF_VAR_HCLOUD_TOKEN }} TF_VAR_hcloud_token: ${{ secrets.TF_VAR_HCLOUD_TOKEN }}
TF_VAR_cloudflare_api_token: ${{ secrets.TF_VAR_CLOUDFLARE_API_TOKEN }} TF_VAR_cloudflare_api_token: ${{ secrets.TF_VAR_CLOUDFLARE_API_TOKEN }}
TF_VAR_domain_name: ${{ secrets.TF_VAR_DOMAIN_NAME }} TF_VAR_domain_name: ${{ secrets.TF_VAR_DOMAIN_NAME }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan
- run: terraform apply -auto-approve
``` ```
## Security notes
- Never commit `terraform/.env` — it is listed in `.gitignore`.
- `.env.example` is a template only; it must never contain real values.
- If a token is ever exposed (commit, paste, logs, screenshots), rotate it
immediately in the Hetzner / Cloudflare consoles and update `.env`.
- Terraform state files (`*.tfstate`) are gitignored; they can contain derived
values such as the resulting DNS hostname.
- For reproducible CI runs, consider committing `terraform/.terraform.lock.hcl`.
+1 -1
View File
@@ -1,5 +1,5 @@
--- ---
ansible_user: "root" ansible_user: "ansible"
sysadmin_user: "alessandrovitali" sysadmin_user: "alessandrovitali"
sysadmin_public_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAm/J+9YG+odym9In9C4iLcrfXlrlPK2TygtI7lBNNpl" sysadmin_public_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAm/J+9YG+odym9In9C4iLcrfXlrlPK2TygtI7lBNNpl"
+2
View File
@@ -0,0 +1,2 @@
[dokploy]
dokploy ansible_host=${public_ip} ansible_user=debian
-1
View File
@@ -6,4 +6,3 @@
- role: studio.ansible.base - role: studio.ansible.base
- role: studio.ansible.users - role: studio.ansible.users
- role: studio.ansible.security - role: studio.ansible.security
- role: dokploy
-14
View File
@@ -1,14 +0,0 @@
---
# Official Dokploy installer
dokploy_installer_url: "https://dokploy.com/install.sh"
dokploy_installer_path: "/usr/local/bin/dokploy-install.sh"
# Pin to a specific release tag for reproducible installs
dokploy_version: "latest"
# Packages the installer expects on a minimal Debian image
# dokploy_prerequisites:
# - ca-certificates
# - curl
# - openssl
# - iproute2
-33
View File
@@ -1,33 +0,0 @@
---
# - name: Install Dokploy prerequisites
# ansible.builtin.apt:
# name: "{{ dokploy_prerequisites }}"
# state: present
# update_cache: true
- name: Download Dokploy installer
ansible.builtin.get_url:
url: "{{ dokploy_installer_url }}"
dest: "{{ dokploy_installer_path }}"
mode: "0755"
# The upstream installer is not idempotent & exits non-zero if the swarm is already initialized. Guard on the presence of the dokploy swarm service
- name: Check if Dokploy is already installed
ansible.builtin.command: docker service inspect dokploy
register: dokploy_installed
changed_when: false
failed_when: false
- name: Install Dokploy
ansible.builtin.command: "{{ dokploy_installer_path }}"
environment:
DOKPLOY_VERSION: "{{ dokploy_version }}"
changed_when: true
when: dokploy_installed.rc != 0
# - name: Update Dokploy (opt-in)
# ansible.builtin.command: "{{ dokploy_installer_path }} update"
# environment:
# DOKPLOY_VERSION: "{{ dokploy_version }}"
# changed_when: true
# when: dokploy_installed.rc == 0
+11 -49
View File
@@ -1,51 +1,22 @@
# Setup the SSH key in the Hetzner Cloud Console # SSH key shared with the VPS
resource "hcloud_ssh_key" "ansible" { resource "hcloud_ssh_key" "ansible" {
name = var.project_name name = var.project_name
public_key = var.ssh_public_key public_key = var.ssh_public_key
} }
# Provision the Hetzner Cloud VPS # Provision a small Hetzner Cloud VPS
resource "hcloud_server" "dokploy" { resource "hcloud_server" "dokploy" {
name = var.project_name name = var.project_name
image = var.hcloud_image image = var.hcloud_image
server_type = var.hcloud_server_type server_type = var.hcloud_server_type
location = var.hcloud_location location = var.hcloud_location
user_data = templatefile("${path.module}/templates/user_data.tpl", { ssh_keys = [hcloud_ssh_key.ansible.id]
ssh_public_key = var.ssh_public_key
})
firewall_ids = [hcloud_firewall.host.id]
public_net { public_net {
ipv4_enabled = true ipv4_enabled = true
} }
} }
# Configure Hetzner Cloud Firewall
resource "hcloud_firewall" "host" {
name = var.project_name
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
# Resolve the Cloudflare zone from its apex domain name # Resolve the Cloudflare zone from its apex domain name
data "cloudflare_zone" "zone" { data "cloudflare_zone" "zone" {
filter = { filter = {
@@ -57,19 +28,10 @@ locals {
public_ip = hcloud_server.dokploy.ipv4_address public_ip = hcloud_server.dokploy.ipv4_address
} }
# Publish the VPS IP as a Cloudflare A record # Automatically publish the VPS IP as a Cloudflare A record
resource "cloudflare_dns_record" "domain" { resource "cloudflare_dns_record" "a" {
zone_id = data.cloudflare_zone.zone.id zone_id = data.cloudflare_zone.zone.id
name = "@" name = var.record_name
type = "A"
content = local.public_ip
proxied = var.cloudflare_proxied
ttl = var.cloudflare_proxied ? 1 : 3600
}
resource "cloudflare_dns_record" "subdomain" {
zone_id = data.cloudflare_zone.zone.id
name = "*"
type = "A" type = "A"
content = local.public_ip content = local.public_ip
proxied = var.cloudflare_proxied proxied = var.cloudflare_proxied
@@ -78,7 +40,7 @@ resource "cloudflare_dns_record" "subdomain" {
# Write IP to Ansible inventory # Write IP to Ansible inventory
resource "local_file" "ansible_inventory" { resource "local_file" "ansible_inventory" {
content = templatefile("${path.module}/templates/ansible_inventory.tpl", { content = templatefile("${path.module}/../ansible/inventory.tpl", {
public_ip = local.public_ip public_ip = local.public_ip
}) })
filename = "${path.module}/../ansible/inventory.ini" filename = "${path.module}/../ansible/inventory.ini"
@@ -1,2 +0,0 @@
[dokploy]
dokploy ansible_host=${public_ip}
-9
View File
@@ -1,9 +0,0 @@
# cloud-config
users:
- name: ansible
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ${ssh_public_key}
ssh_pwauth: false
disable_root: true
+1
View File
@@ -4,6 +4,7 @@ hcloud_server_type = "cx23"
hcloud_image = "debian-13" hcloud_image = "debian-13"
hcloud_location = "nbg1" hcloud_location = "nbg1"
record_name = "dokploy"
cloudflare_proxied = true cloudflare_proxied = true
# Note: Use file() to easily read your local SSH public key # Note: Use file() to easily read your local SSH public key
+6
View File
@@ -45,6 +45,12 @@ variable "domain_name" {
description = "Cloudflare zone (apex domain) in which to create the A record" description = "Cloudflare zone (apex domain) in which to create the A record"
} }
variable "record_name" {
type = string
description = "DNS record name relative to the zone; use \"@\" for the apex"
default = "dokploy"
}
variable "cloudflare_proxied" { variable "cloudflare_proxied" {
type = bool type = bool
description = "Whether Cloudflare should proxy (orange-cloud) the A record" description = "Whether Cloudflare should proxy (orange-cloud) the A record"