A streamlined Terraform module to create a Hetzner Cloud virtual machine that automatically joins a Tailscale network with secure firewall configuration and automatic cleanup.
- 🚀 Automated Tailscale Setup: Automatically installs and configures Tailscale on server boot
- 🔒 Secure Firewall: Pre-configured firewall rules optimized for Tailscale connectivity
- 🔑 SSH-less Access: Uses Tailscale SSH for secure remote access (no need for port 22)
- ⚡ Cloud-init Integration: Supports custom user commands via cloud-init
- 🧹 Automatic Cleanup: Removes Tailscale devices from network when infrastructure is destroyed
- 🐳 Docker Ready: Includes Docker and Docker Compose pre-installed
- �️ Security Hardening: Includes fail2ban and ufw packages for additional security
This module creates:
- A Hetzner Cloud server with specified configuration
- A Tailscale auth key (ephemeral, preauthorized)
- A firewall with Tailscale-optimized rules
- Cloud-init configuration for automatic Tailscale setup
- Automatic Tailscale device cleanup on destroy
- Terraform >= 0.13
- Hetzner Cloud API token
- Tailscale API key with appropriate permissions
- SSH key uploaded to Hetzner Cloud
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.17"
}
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45"
}
}
}module "tailscale_server" {
source = "./path/to/this/module"
server_name = "my-tailscale-server"
image = "ubuntu-22.04"
server_type = "cx22"
location = "nbg1"
ssh_keys = ["my-ssh-key"]
tailscale_api_key = var.tailscale_api_key
tailscale_tailnet = var.tailscale_tailnet
}module "tailscale_server" {
source = "./path/to/this/module"
server_name = "web-server"
image = "ubuntu-22.04"
server_type = "cx22"
location = "fsn1"
ssh_keys = ["my-ssh-key"]
username = "myuser" # Custom username instead of default "sprantic"
tailscale_api_key = var.tailscale_api_key
tailscale_tailnet = var.tailscale_tailnet
# Custom commands to run after Tailscale setup
runcmd = <<-EOT
# Install additional software
apt-get update
apt-get install -y htop
# Start a web server (Docker is already installed by the module)
docker run -d -p 80:80 nginx
EOT
}
output "server_ip" {
value = module.tailscale_server.ip_address
}module "tailscale_server" {
source = "./path/to/this/module"
server_name = "web-app-server"
image = "ubuntu-22.04"
server_type = "cx22"
location = "fsn1"
ssh_keys = ["my-ssh-key"]
tailscale_api_key = var.tailscale_api_key
tailscale_tailnet = var.tailscale_tailnet
# Docker Compose configuration from local file
docker_compose_project_name = "webapp"
docker_compose_yaml = file("${path.module}/docker-compose.yml")
}docker-compose.yml:
version: '3.8'
services:
web:
image: nginx:latest
ports:
- "80:80"
restart: unless-stopped
redis:
image: redis:alpine
restart: unless-stopped
app:
image: node:18-alpine
working_dir: /app
ports:
- "3000:3000"
depends_on:
- redis
restart: unless-stoppedThis module is used as the base for terraform-hcloud-gitlab, which wraps it with GitLab-specific configuration:
module "tailscale_server" {
source = "../terraform-hcloud-tailscale-server"
server_name = "sprantic-gitlab"
image = "ubuntu-22.04"
server_type = "cx33" # 4 vCPU / 8GB RAM minimum for GitLab
location = "nbg1"
ssh_keys = ["my-ssh-key"]
tailscale_api_key = var.tailscale_api_key
tailscale_tailnet = var.tailscale_tailnet
docker_compose_project_name = "gitlab"
docker_compose_yaml = templatefile("templates/docker-compose.yml.tftpl", {
gitlab_version = "18.7.5-ce.0"
external_url = "http://sprantic-gitlab"
external_host = "sprantic-gitlab"
gitlab_omnibus_extra_config = ""
})
# Swap + post-deploy commands (e.g. backup cron) passed via runcmd
runcmd = "fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile"
}GitLab is accessed via Tailscale MagicDNS at http://<server_name> — no public ports exposed. The runcmd variable is executed as a post-deploy script after Docker Compose starts. Avoid injecting multiline heredocs or special characters; keep it to simple shell commands.
This module includes several examples to help you get started:
A simple example showing basic usage with minimal configuration:
- Single server deployment
- Default settings
- Basic Tailscale integration
A comprehensive example demonstrating advanced features:
- Multiple servers (web, database, monitoring)
- Custom username configuration
- Complex application deployments (Docker, PostgreSQL, Prometheus, Grafana)
- Service-specific configurations
To use any example:
cd examples/basic # or examples/advanced
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your values
terraform init
terraform apply| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| server_name | Name of the server | string |
n/a | yes |
| image | The image ID that will be used to create the instance | string |
n/a | yes |
| server_type | The Hetzner server type that will be used to create the instance | string |
n/a | yes |
| location | The Hetzner location code that will be used to create the instance | string |
n/a | yes |
| ssh_keys | The public key that will be used to check ssh access | list(string) |
n/a | yes |
| runcmd | The runcommand part of the cloud_init script | string |
"" |
no |
| username | The username for the created user on the server | string |
"sprantic" |
no |
| tailscale_api_key | Tailscale API key for device management (required for automatic cleanup on destroy) | string |
n/a | yes |
| tailscale_tailnet | Tailscale tailnet name (e.g., 'example.com' or 'user@domain.com') | string |
n/a | yes |
| docker_compose_yaml | Docker Compose YAML configuration to be deployed on the server | string |
"" |
no |
| docker_compose_project_name | Name for the Docker Compose project (defaults to server name) | string |
"" |
no |
ash- Ashburn, VAfsn1- Falkenstein, Germanyhel1- Helsinki, Finlandnbg1- Nuremberg, Germanyhil- Hillsboro, OR
cx22- 2 vCPU, 4 GB RAM
| Name | Description |
|---|---|
| ip_address | The public IPv4 address of the created server |
The module creates a firewall with the following rules:
- ICMP: Allow ping from anywhere
- UDP 41641: Tailscale default port for peer-to-peer connections
- UDP 3478: STUN protocol for NAT traversal
- TCP 1-65535: All outbound TCP traffic (temporary rule for stability)
Note: SSH (port 22) is intentionally blocked as Tailscale SSH is used instead.
The module uses a multi-part cloud-init configuration that:
- Creates a user: Configurable username (default:
sprantic) with sudo privileges - Installs packages: Basic packages and Docker with Docker Compose
- Updates system: Runs package update and upgrade
- Installs Docker: Sets up Docker CE with proper user permissions
- Installs Tailscale: Downloads and installs the latest Tailscale client
- Configures networking: Enables IP forwarding for potential exit node usage
- Joins Tailscale network: Uses the generated auth key to join
- Enables Tailscale SSH: Allows SSH access through Tailscale
- Deploys Docker Compose: Automatically deploys provided Docker Compose configuration
- Runs custom commands: Executes any commands provided in
runcmdvariable
Set the following environment variables for provider authentication:
# Hetzner Cloud
export HCLOUD_TOKEN="your-hetzner-cloud-api-token"
# Tailscale
export TAILSCALE_API_KEY="your-tailscale-api-key"
export TAILSCALE_TAILNET="your-tailnet-name"- The server is configured to use Tailscale SSH instead of traditional SSH
- Firewall rules are restrictive and only allow necessary Tailscale traffic
- fail2ban is installed for additional protection against brute force attacks
- The Tailscale auth key is ephemeral and expires after 1 hour
- All packages are updated during initial setup
- Automatic device cleanup prevents orphaned entries in Tailscale admin console
After deployment, you can access your server through Tailscale:
# SSH via Tailscale (if Tailscale is installed on your local machine)
ssh <username>@<server-tailscale-ip>
# Or use the Tailscale web interface for browser-based SSHThis module includes automatic cleanup of Tailscale devices when you run terraform destroy. This prevents orphaned device entries from remaining in your Tailscale admin console.
- When the server is created, it joins Tailscale with a specific hostname matching the
server_name - On
terraform destroy, a local provisioner automatically:- Queries the Tailscale API to find the device by hostname
- Removes the device from your Tailscale network
- Cleans up any associated device entries
tailscale_api_key: A Tailscale API key with device management permissionstailscale_tailnet: Your Tailscale tailnet identifier
Your Tailscale API key needs the following permissions:
- Devices: Read and Write access to manage device lifecycle
You can create an API key at: https://login.tailscale.com/admin/settings/keys
This module includes built-in support for deploying Docker Compose applications automatically during server initialization.
- Docker Installation: Docker and Docker Compose are automatically installed during cloud-init
- Configuration Deployment: Your Docker Compose YAML is written to
/opt/docker-compose/<project-name>/docker-compose.yml - Automatic Startup: After Docker is ready, the compose application is automatically started
- Project Management: Uses a configurable project name (defaults to server name) for isolation
- Base64 Encoding: Uses secure base64 encoding to avoid YAML parsing conflicts
module "my_server" {
source = "./path/to/module"
# ... other required variables ...
# Recommended: Use local file
docker_compose_yaml = file("${path.module}/docker-compose.yml")
docker_compose_project_name = "myapp" # Optional, defaults to server_name
}Alternative: Inline YAML (not recommended for complex configurations):
docker_compose_yaml = <<-EOT
version: '3.8'
services:
web:
image: nginx:alpine
ports:
- "80:80"
EOT- Automatic image pulling: Images are pulled before starting services
- Health monitoring: Shows service status after deployment
- Error handling: Includes timeouts and error reporting
- Project isolation: Uses Docker Compose project names for isolation
- Docker Compose file:
/opt/docker-compose/<project-name>/docker-compose.yml - Working directory:
/opt/docker-compose/<project-name>/ - Logs: Available via
docker compose logs
# SSH into the server via Tailscale
ssh username@server-tailscale-ip
# Navigate to project directory (replace <project-name> with your project name)
cd /opt/docker-compose/<project-name>
# View running services (with project name)
COMPOSE_PROJECT_NAME=<project-name> docker compose ps
# View logs
COMPOSE_PROJECT_NAME=<project-name> docker compose logs
# Restart services
COMPOSE_PROJECT_NAME=<project-name> docker compose restart
# Update and redeploy
COMPOSE_PROJECT_NAME=<project-name> docker compose pull
COMPOSE_PROJECT_NAME=<project-name> docker compose up -d- Check cloud-init logs:
sudo cloud-init status --long - Verify Tailscale service:
sudo systemctl status tailscaled
- Ensure Tailscale is running on your local machine
- Check if the server is online in Tailscale admin console
- Verify SSH is enabled:
tailscale status --json | jq .Self.HostName
- Check cloud-init logs:
sudo cat /var/log/cloud-init-output.log - Verify script syntax and permissions
- Fork the repository
- Create a feature branch
- Make your changes
- Test thoroughly
- Submit a pull request
This module is released under the MIT License. See LICENSE file for details.
Created and maintained by the sprantic team.