Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8291a774b | ||
|
|
31a05abb62 | ||
|
|
918f74c715 | ||
|
|
8419c9978c | ||
|
|
17fce8c8ee | ||
|
|
a49e565124 | ||
|
|
d80510e61d | ||
|
|
97eaa8cf51 | ||
|
|
e4cccc8a3a |
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
.envrc
|
||||||
|
|
||||||
# Ansible
|
# Ansible
|
||||||
ansible/inventory.ini
|
ansible/inventory.ini
|
||||||
|
|||||||
@@ -2,45 +2,15 @@
|
|||||||
|
|
||||||
Terraform + Ansible setup for a self-hosted [Dokploy](https://dokploy.com/) instance.
|
Terraform + Ansible setup for a self-hosted [Dokploy](https://dokploy.com/) instance.
|
||||||
|
|
||||||
- **Terraform** provisions a Hetzner Cloud VPS, registers an SSH key, creates a Cloudflare DNS record, and writes the Ansible inventory.
|
## Setup
|
||||||
- **Ansible** configures the host afterwards (base / users / security roles).
|
|
||||||
|
|
||||||
## Repository layout
|
1. Create a Hetzner Cloud API key
|
||||||
|
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
|
Secrets live in `terraform/.env` and are injected as `TF_VAR_*` environment
|
||||||
|
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=...
|
||||||
@@ -48,63 +18,53 @@ TF_VAR_cloudflare_api_token=...
|
|||||||
TF_VAR_domain_name=...
|
TF_VAR_domain_name=...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local setup
|
#### direnv
|
||||||
|
|
||||||
### Prerequisites
|
Setup direnv on the development machine and add `terraform/.envrc`:
|
||||||
|
|
||||||
- Terraform `>= 1.0`
|
```env
|
||||||
- [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
|
||||||
```
|
```
|
||||||
|
|
||||||
direnv loads the variables when you `cd` into `terraform/` and unloads them when
|
Then load the variables into the shell:
|
||||||
you leave, so secrets never linger in your shell.
|
|
||||||
|
|
||||||
### Without direnv
|
```sh
|
||||||
|
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
|
||||||
terraform plan
|
tofu plan
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use a wrapper that keeps the variables contained to the `terraform` subprocess:
|
Or use a wrapper that keeps the variables contained to the `tofu` subprocess:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -a
|
set -a
|
||||||
. ./.env
|
. ./.env
|
||||||
set +a
|
set +a
|
||||||
exec terraform "$@"
|
exec tofu "$@"
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
CI reads the same variables from its secret store — no `.env` file is needed.
|
CI reads the same variables from the git forge's secret store — no `.env` file is needed.
|
||||||
|
|
||||||
|
Add the necessary variables
|
||||||
|
|
||||||
GitHub Actions example:
|
GitHub Actions example:
|
||||||
|
|
||||||
@@ -113,21 +73,4 @@ 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,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
ansible_user: "ansible"
|
ansible_user: "root"
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
[dokploy]
|
|
||||||
dokploy ansible_host=${public_ip} ansible_user=debian
|
|
||||||
@@ -6,3 +6,4 @@
|
|||||||
- 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
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
# - 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
|
||||||
+49
-11
@@ -1,22 +1,51 @@
|
|||||||
# SSH key shared with the VPS
|
# Setup the SSH key in the Hetzner Cloud Console
|
||||||
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 a small Hetzner Cloud VPS
|
# Provision the 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
|
||||||
ssh_keys = [hcloud_ssh_key.ansible.id]
|
user_data = templatefile("${path.module}/templates/user_data.tpl", {
|
||||||
|
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 = {
|
||||||
@@ -28,10 +57,19 @@ locals {
|
|||||||
public_ip = hcloud_server.dokploy.ipv4_address
|
public_ip = hcloud_server.dokploy.ipv4_address
|
||||||
}
|
}
|
||||||
|
|
||||||
# Automatically publish the VPS IP as a Cloudflare A record
|
# Publish the VPS IP as a Cloudflare A record
|
||||||
resource "cloudflare_dns_record" "a" {
|
resource "cloudflare_dns_record" "domain" {
|
||||||
zone_id = data.cloudflare_zone.zone.id
|
zone_id = data.cloudflare_zone.zone.id
|
||||||
name = var.record_name
|
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
|
||||||
@@ -40,7 +78,7 @@ resource "cloudflare_dns_record" "a" {
|
|||||||
|
|
||||||
# Write IP to Ansible inventory
|
# Write IP to Ansible inventory
|
||||||
resource "local_file" "ansible_inventory" {
|
resource "local_file" "ansible_inventory" {
|
||||||
content = templatefile("${path.module}/../ansible/inventory.tpl", {
|
content = templatefile("${path.module}/templates/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"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[dokploy]
|
||||||
|
dokploy ansible_host=${public_ip}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
@@ -4,7 +4,6 @@ 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
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user