Home | Send Feedback | Share on Bluesky |

Set Up frp Server on Hetzner Cloud with Pulumi

Published: 4. March 2026  •  iac

In a previous blog post, I described a way to set up a WireGuard VPN server on Hetzner Cloud with Pulumi. This setup allows you to spin up a VPN server whenever you need it and tear it down when you are done, which is a great way to save costs.

Another service I only use from time to time is a reverse proxy server that allows me to expose local services from my home lab to the internet. For this purpose, I'll show you how to set up frp (Fast Reverse Proxy) on Hetzner Cloud using Pulumi. frp is a high-performance reverse proxy application that supports multiple protocols (TCP, UDP, HTTP, HTTPS) and can help you expose a local server behind a NAT or firewall to the internet through a server with a public IP address.

Local_Network

Local_Machine

1 - Request

2 - Encrypted Tunnel

3 - Forward Request

4 - Return Data

5 - Encrypted Return

6 - Response

Local Service

FRP Client + TLS

Internet User

Public Server - frps + TLS

Prerequisites

To follow this blog post, you need:

For this example, I wrote the Pulumi code in TypeScript, so you also need Node.js or Bun. Pulumi supports many programming languages, including Python, Go, C#, and Java, and you can use any of them to write your Pulumi code. The concepts transfer easily to other languages, so you can use the one you are most comfortable with.

Pulumi accesses Hetzner Cloud via its API. For this reason, you need to create an API token in your Hetzner Cloud account. In the menu on the left side of the Hetzner Cloud console, click on "Security" and, on the screen that opens, click on the "API tokens" tab. Create a new API token by clicking the "Generate API token" button. Make sure that the API token has read and write permissions.

Initialize a Pulumi Project

Create a new directory for your Pulumi project and navigate into it. Initialize a new Pulumi project with the following command:

pulumi new typescript

Enter the name of your project, a description, the name of the stack, and a password for encrypting the secrets.

Install the Pulumi Hetzner package by running the following command:

npm install @pulumi/hcloud

Next, we will add the Hetzner API token to the Pulumi configuration. Run the following command:

pulumi config set --secret hcloud:token <your-hetzner-api-token>

The name hcloud:token here is important because the Pulumi Hetzner package expects the API token to be stored under this name in the configuration. If you don't want to store the token in the Pulumi configuration, you can set the environment variable HCLOUD_TOKEN instead.

Now we need to configure the frp-specific settings. Set the following configuration values:

pulumi config set serverRegion hel1
pulumi config set frpServerPort 7000
pulumi config set frpDashboardPort 7500
pulumi config set frpDashboardUser admin
pulumi config set exposedTcpPort 8080
pulumi config set --secret frpServerToken "your-secure-random-token"
pulumi config set --secret frpDashboardPassword "your-secure-dashboard-password"

The exposedTcpPort is the port that will be opened on the server for your proxied services. The server region determines where your server will be located. hel1 is Helsinki, Finland, but you can choose any Hetzner region like nbg1 (Nuremberg), fsn1 (Falkenstein), ash (Ashburn), or hil (Hillsboro).

Now we are ready to provision the frp server on Hetzner Cloud.

Provision the frp Server

The Pulumi code for provisioning the frp server is located in the index.ts file. The program first reads configuration values and secrets.

const config = new pulumi.Config();
const serverRegion = config.require("serverRegion");
const frpServerToken = config.requireSecret("frpServerToken");
const frpServerPort = config.requireNumber("frpServerPort");
const frpDashboardPort = config.requireNumber("frpDashboardPort");
const frpDashboardUser = config.require("frpDashboardUser");
const frpDashboardPassword = config.requireSecret("frpDashboardPassword");
const exposedTcpPort = config.requireNumber("exposedTcpPort");

index.ts

Next, the program reads the cloud-init script from an external file and prepares it by replacing the placeholders with the actual configuration values.

const cloudInitScript = fs.readFileSync(path.join(__dirname, "frp.yaml"), "utf8");
const userData = pulumi.all([
  frpServerToken,
  frpDashboardPassword,
]).apply(([serverToken, dashPassword]) => {
  return cloudInitScript
    .replace(/FRP_SERVER_PORT_PLACEHOLDER/g, frpServerPort.toString())
    .replace(/FRP_DASHBOARD_PORT_PLACEHOLDER/g, frpDashboardPort.toString())
    .replace(/FRP_DASHBOARD_USER_PLACEHOLDER/g, frpDashboardUser)
    .replace(/FRP_DASHBOARD_PASSWORD_PLACEHOLDER/g, dashPassword)
    .replace(/FRP_SERVER_TOKEN_PLACEHOLDER/g, serverToken)
    .replace(/FRP_EXPOSED_TCP_PORT_PLACEHOLDER/g, exposedTcpPort.toString());
});

index.ts

Next, we generate an SSH key pair to access the server securely after installation. The cloud-init script automatically generates a CA certificate and client certificates for mutual TLS authentication for the frp connection, and we need to access the server to download these certificates to our local machine.

const opensshPrivateKeyPath = path.join(__dirname, "frp-server-key");
try {
  try {
    fs.unlinkSync(opensshPrivateKeyPath);
    fs.unlinkSync(`${opensshPrivateKeyPath}.pub`);
  } catch (error) {
    console.log(`Cannot remove existing SSH key files: ${(error as Error).message}`);
  }

  execSync(`ssh-keygen -t ed25519 -f "${opensshPrivateKeyPath}" -N "" -C "root@frp-server"`, {
    stdio: 'pipe'
  });

} catch (error) {
  console.error("Failed to generate SSH key pair:", error as Error);
  throw new Error("SSH key generation failed. Make sure ssh-keygen is installed and available in PATH.");
}

setSecureFilePermissions(opensshPrivateKeyPath);

const sshPublicKey = fs.readFileSync(`${opensshPrivateKeyPath}.pub`, 'utf8').trim();

const sshKey = new hcloud.SshKey("frp-ssh-key", {
  name: `frp-server-ssh-key`,
  publicKey: sshPublicKey,
});

index.ts

The next code block provisions the server on Hetzner Cloud with the specified configuration and cloud-init script. The server is created with the cx23 type, which is currently the cheapest virtual server type from Hetzner.

Now we can provision the server:

const frpServer = new hcloud.Server("frp-server", {
  name: "frp-server",
  sshKeys: [sshKey.id],
  serverType: "cx23",
  image: "debian-13",
  location: serverRegion,
  userData: userData,
});

index.ts

Next, Pulumi configures firewall rules to allow incoming traffic on the necessary ports: SSH (22), the frp server port (7000 by default), and the exposed TCP port for your proxied services. In this example, we run a web service on the local machine that listens on port 8080 and expose it to the internet through the frp server. For this demo setup, frp is configured to forward traffic from port 8080 on the Hetzner server to the local machine, so we need to allow incoming traffic on port 8080 on the server as well. Note that the ports don't have to be same. You can configure any port to forward traffic to any port on the local machine.

const firewallRules: hcloud.types.input.FirewallRule[] = [
  {
    direction: "in",
    protocol: "tcp",
    port: "22",
    sourceIps: ["0.0.0.0/0", "::/0"],
    description: "Allow SSH access",
  },
  {
    direction: "in",
    protocol: "tcp",
    port: frpServerPort.toString(),
    sourceIps: ["0.0.0.0/0", "::/0"],
    description: "Allow frp server connections",
  },
  {
    direction: "in",
    protocol: "tcp",
    port: exposedTcpPort.toString(),
    sourceIps: ["0.0.0.0/0", "::/0"],
    description: "Allow configured TCP port for web service",
  },
];

const frpFirewall = new hcloud.Firewall("frp-firewall", {
  name: "frp-server-firewall",
  rules: firewallRules,
});

new hcloud.FirewallAttachment("frp-firewall-attachment", {
  firewallId: frpFirewall.id.apply(id => parseInt(id, 10)),
  serverIds: [frpServer.id.apply(id => parseInt(id, 10))],
});

index.ts

Installing and Configuring frp

Pulumi is used to provision resources, not to install software on provisioned machines. Fortunately, most cloud providers offer a way to run scripts after resources are created. The de facto standard for this is cloud-init. cloud-init scripts are written in YAML and can be used to install software, configure the server, and run commands.

All images provided by Hetzner Cloud support cloud-init, so we can use it to run scripts on the provisioned server. As shown above, to pass the cloud-init script to the server, we use the userData property of the hcloud.Server resource.

The cloud-init script used in this example performs the following steps:

You can find the complete cloud-init script here.

Deploy the frp Server

You can now run pulumi up to provision the frp server. After the provisioning is complete, Pulumi prints the server's public IP address and connection information in the console output.

The deployment typically takes 2–3 minutes, including the time for cloud-init to download and configure frp.

Accessing the Dashboard

The frp dashboard is configured to listen only on localhost for security reasons. To access it, you need to create an SSH tunnel to the server. Use the SSH tunnel command provided in the Pulumi output:

ssh -i frp-server-key -L 7500:localhost:7500 root@YOUR_SERVER_IP

Then open your web browser and navigate to http://localhost:7500. Log in with the dashboard credentials you configured.

Client Configuration

Important: The frp server enforces TLS encryption. You must first download the CA certificate from the server before configuring your client.

Download the CA Certificate

ssh -i frp-server-key root@YOUR_SERVER_IP "cat /etc/ssl/private/frp-ca.crt" > frp-ca.crt

Download the Client Certificates

The server also generates client certificates for mutual TLS authentication:

ssh -i frp-server-key root@YOUR_SERVER_IP "cat /etc/ssl/certs/frpc.crt" > frpc.crt
ssh -i frp-server-key root@YOUR_SERVER_IP "cat /etc/ssl/private/frpc.key" > frpc.key

Create Client Configuration

The Pulumi program wrote a frpc.toml configuration file for the client based on this template:

# frpc.toml
serverAddr = "your-frps-server-ip"
serverPort = SERVER_PORT_PLACEHOLDER

auth.method = "token"
auth.token = "your-frp-server-token"

transport.tls.enable = true
transport.tls.serverName = "your-frps-server-ip"
transport.tls.certFile = "/etc/frpc.crt"
transport.tls.keyFile = "/etc/frpc.key"
transport.tls.trustedCaFile = "/etc/frp-ca.crt"

[[proxies]]
name = "web"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8080
remotePort = FRP_EXPOSED_TCP_PORT_PLACEHOLDER
transport.useEncryption = true
transport.useCompression = true

frpc.toml.template

With this configuration in place, the connection between the frp client and server is encrypted using mutual TLS with ED25519 certificates. The client validates the server's certificate against the trusted CA certificate, and the server validates the client's certificate as well.

Client Examples

For demonstration, I created a Dockerfile that sets up an frp client with the necessary certificates and configuration to connect to the frp server. The Dockerfile also includes a simple web server that listens on port 8080, which we expose through the frp server.

FROM debian:13

RUN apt-get update && apt-get install -y \
    wget \
    python3 \
    && rm -rf /var/lib/apt/lists/*

RUN wget https://github.com/fatedier/frp/releases/download/v0.67.0/frp_0.67.0_linux_amd64.tar.gz \
    && tar -xzf frp_0.67.0_linux_amd64.tar.gz \
    && mv frp_0.67.0_linux_amd64/frpc /usr/local/bin/ \
    && rm -rf frp_0.67.0_linux_amd64*

RUN mkdir /www && echo "<html><body><h1>Hello from frp client!</h1></body></html>" > /www/index.html

COPY frpc.toml /etc/frpc.toml
COPY frpc.crt /etc/frpc.crt
COPY frpc.key /etc/frpc.key
COPY frp-ca.crt /etc/frp-ca.crt

CMD ["/bin/sh", "-c", "python3 -m http.server 8080 -d /www & frpc -c /etc/frpc.toml"]

Dockerfile

Build and run the Docker container with the following commands:

docker build -t frp-client .
docker run -d --name frp-client frp-client

You can now check whether everything is working by navigating to http://YOUR_HETZNER_SERVER_IP:8080 in your web browser. You should see the "Hello from frp client!" message, which means that the Hetzner server is successfully forwarding traffic to the frp client and the local web server.

Destroying the Resources

With this configuration, you now have an easy way to start a frp server whenever you need it and tear it down when you are done. Hetzner Cloud charges you only for the time the server is running, so you can save costs by not running the server all the time.

To destroy the resources when you're done:

pulumi destroy

Wrapping Up

In this blog post, we have seen how to set up a frp server on Hetzner Cloud using Pulumi. We have covered the prerequisites, how to initialize a Pulumi project, how to provision the server, and how to configure the frp client. With this setup, you can easily expose local services from your home lab to the internet through a secure reverse proxy server that you can start and stop as needed.

This frp example is a basic setup that forwards port 8080 from the Hetzner server to the frp client. However, frp supports many more features and configurations, such as UDP forwarding, HTTP/HTTPS proxying, load balancing, and more. You can customize the cloud-init script and client configuration to fit your specific use case and requirements. Check out the frp documentation for more information on available features and configurations.