Using Pulumi with Hetzner

Consolidating domains and moving cloud providers has been a "TODO" on my org list for quite a while.

I'm currently hosting on both Bluehost and Digital Ocean, and both cost significantly more than I would like to be paying. Hetzner got on my radar a few months ago, and the cost differential (a monthly reduction of 80%) was highly appealing.

While I haven't finished this process (goal is by the end of the year), I did at least start working on the first part of better managing static (and SPA) sites with Pulumi. In the interest of not borking any of my existing systems, I took a domain I already owned (spitedriven.dev), but had no actual site to see how hard it would be to set up Pulumi to manage my infrastructure.

Why Pulumi?

I've used Terraform for work, but I've been interested in Pulumi for a while as an open source solution with Python support (among others). Pulumi also had an Hcloud Provider available, so it looked like this could be pretty easy to set up.

What I Did

The first step was to install the Pulumi CLI (and upgrade XCode). While Pulumi has a bunch of templates, it didn't have one for Hetzner Cloud (yet), so I opted to start a project just using

pulumi new python

I was initially hoping that I could even manage my DNS with Hetzner DNS and Pulumi, but that proved to be an unsuccesful detour. While there is a Github repo for pulumi-hetznerdns, it doesn't look like it's being maintained. The PyPI package has vanished, and attempts to build the site package locally failed. I might revisit this later when I have more time, but it was only a minimal addition of brain space to add the A records to Porkbun to point to the right server.

One thing that was a bit annoying to troubleshoot was that I decided to use uv for the first time to manage the venv for Pulumi. I kept having issues with pulumi up wiping out the site packages, which then resulted in ImportError: No module named hetzner_hcloud. I needed to add

dependencies:
  - requirements.txt

to my Pulumi.yaml file

I also had to manually recreate all the secrets for my Pulumi.dev.yaml via

pulumi config set --secret dev:${VAR} ${VAR_VALUE}

because I created a new Pulumi stack of dev.

Pulumi __main__.py

My final __main__.py file for the Pulumi project looked like this:

import pulumi
from pulumi import ResourceOptions
from pulumi_hcloud import Provider, Server
import pulumi_command as command

config = pulumi.Config()
hcloud = Provider("hcloud", token=config.require_secret("hetzner_token"))

server = Server.get(
    "existing-server",
    config.require("server_id"),
    opts=ResourceOptions(provider=hcloud),
)

certbot_install = command.remote.Command(
    "install-certbot",
    connection={
        "host": server.ipv4_address,
        "user": "root",
        "private_key": config.require("ssh_private_key"),
    },
    create="apt update && apt install -y certbot python3-certbot-nginx",
)

site_dir = command.remote.Command(
    "create-dir",
    connection={
        "host": server.ipv4_address,
        "user": "root",
        "private_key": config.require("ssh_private_key"),
    },
    create=pulumi.Output.concat(
        "mkdir -p /var/www/",
        config.require("domain"),
        " && ",
        "chown -R www-data:www-data /var/www/",
        config.require("domain"),
    ),
    opts=ResourceOptions(depends_on=[certbot_install]),
)

site_sync = command.local.Command(
    "sync-site",
    create=pulumi.Output.concat(
        "rsync -avz -e 'ssh -i ",
        config.require("ssh_private_key_path"),
        "' ",
        config.require("site_path"),
        "/* root@",
        server.ipv4_address,
        ":/var/www/",
        config.require("domain"),
        "/",
    ),
    opts=ResourceOptions(depends_on=[site_dir]),
)

nginx_config = command.remote.Command(
    "nginx-config",
    connection={
        "host": server.ipv4_address,
        "user": "root",
        "private_key": config.require("ssh_private_key"),
    },
    create=pulumi.Output.concat(
        "cat > /etc/nginx/sites-available/",
        config.require("domain"),
        " << 'EOL'\n",
        "server {\n",
        "    listen 80;\n",
        "    listen [::]:80;\n",
        "    server_name ",
        config.require("domain"),
        " www.",
        config.require("domain"),
        ";\n",
        "    root /var/www/",
        config.require("domain"),
        ";\n",
        "    index index.html;\n",
        "}\n",
        "EOL\n",
        "ln -sf /etc/nginx/sites-available/",
        config.require("domain"),
        " /etc/nginx/sites-enabled/",
        " && nginx -t && systemctl reload nginx",
    ),
    opts=ResourceOptions(depends_on=[site_sync]),
)

ssl_cert = command.remote.Command(
    "ssl-cert",
    connection={
        "host": server.ipv4_address,
        "user": "root",
        "private_key": config.require("ssh_private_key"),
    },
    create=pulumi.Output.concat(
        "certbot --nginx -d ",
        config.require("domain"),
        " -d www.",
        config.require("domain"),
        " --non-interactive --agree-tos -m ",
        config.require("email"),
    ),
    opts=ResourceOptions(depends_on=[nginx_config]),
)

pulumi.export("server_ip", server.ipv4_address)

which handles almost everything I've previously been handling manually, including setting SSL certs, NGINX configuration, and site syncing from my local machine. Since this is just a static site, I anticipate some additional complications with porting over some applications (including bridge.watch) which is running off of containers with docker-compose.

The only other hiccup I had with this setup had to do with my SSH keys in Hetzner Cloud. While I had created a key with ssh-keygen and copied the public key to Hetzner Cloud, I was still being prompted for a password on both the server itself at the CLI, and when trying to run ssh root@${MY_SERVER}. It turned out that I needed to either run:

ssh-add ~/.ssh/${MY_HETZNER_KEY}

for adding it to the ssh-agent

or explicitly passing it in the command line:

ssh -v -i ~/.ssh/${MY_HETZNER_KEY} root@${MY_HETZNER_SERVER}

pulumi up gave me a preview of what would happen on my server, and then gave me a readout of the status for each command I'd specified in the project file:

✦ ❯ pulumi up                                                                                       
Previewing update (dev)

     Type                        Name             Plan       
 +   pulumi:pulumi:Stack         dev-dev          create     
 +   ├─ pulumi:providers:hcloud  hcloud           create     
 +   ├─ command:remote:Command   install-certbot  create     
 +   ├─ command:remote:Command   create-dir       create     
 +   ├─ command:local:Command    sync-site        create     
 +   ├─ command:remote:Command   nginx-config     create     
 +   └─ command:remote:Command   ssl-cert         create     

Resources:
    + 7 to create

Do you want to perform this update? yes
Updating (dev)

     Type                        Name             Status              
 +   pulumi:pulumi:Stack         dev-dev          created (16s)       
 +   ├─ pulumi:providers:hcloud  hcloud           created (0.26s)     
 +   ├─ command:remote:Command   install-certbot  created (7s)        
 +   ├─ command:remote:Command   create-dir       created (0.64s)     
 +   ├─ command:local:Command    sync-site        created (0.93s)     
 +   ├─ command:remote:Command   nginx-config     created (0.72s)     
 +   └─ command:remote:Command   ssl-cert         created (3s)        

Resources:
    + 7 created

Duration: 21s

And with that, I have the scaffold for adding all my other static sites to Hetnzer! spitedriven.dev

Landing page for spitedriven.dev