Skip to content

Part 4: Networking & Load Balancing

Right now your Service is served straight off the Server’s public IP, and every port on the Server is open to the internet. In this final part you’ll make it production-shaped: a private Network between the machines, a managed Load Balancer in front, and a Firewall that closes the app port to everyone but the LB.

Add a Network to src/server.ts and attach the Server to it:

src/server.ts
import * as Hetzner from "alchemy/Hetzner";
export const Net = Hetzner.Network("net", {
ipRange: "10.0.0.0/16",
subnets: [
{ type: "cloud", networkZone: "eu-central", ipRange: "10.0.1.0/24" },
],
});
export const Box = Hetzner.Server("box", {
serverType: "cx22",
image: "ubuntu-24.04",
location: "nbg1",
sshKeys: [Key],
networks: [Net],
});
export const Data = Hetzner.Volume("data", { /* ... */ });

nbg1 lives in the eu-central network zone. Passing Net — a module-scope resource declaration — directly in networks is enough: resource-valued props accept the resource or an Effect producing it, and Alchemy resolves the reference and orders the deploy.

Declare the Load Balancer in src/server.ts, targeting the Server over its private IP:

src/server.ts
export const Edge = Hetzner.LoadBalancer("edge", {
location: "nbg1",
loadBalancerType: "lb11",
networks: [Net],
targets: [{ type: "server", server: Box, usePrivateIp: true }],
services: [
{
protocol: "http",
listenPort: 80,
destinationPort: 3000,
healthCheck: {
protocol: "http",
port: 3000,
interval: 3,
timeout: 2,
retries: 2,
http: { path: "/health" },
},
},
],
});

The LB listens on port 80 and forwards to port 3000 on the Server, reaching it through the private Network (usePrivateIp: true). Its health check polls the same /health route the deploy already uses — an unhealthy Service is pulled out of rotation automatically.

With traffic flowing through the private network, close the public app port. Apply a Firewall to the Server that allows SSH from anywhere but port 3000 only from inside the Network:

src/server.ts
export const Wall = Hetzner.Firewall("wall", {
applyTo: [Box],
rules: [
{
direction: "in",
protocol: "tcp",
port: "22",
sourceIps: ["0.0.0.0/0", "::/0"],
description: "ssh",
},
{
direction: "in",
protocol: "tcp",
port: "3000",
sourceIps: ["10.0.0.0/16"],
description: "api via private net",
},
],
});

A Hetzner Firewall is default-deny for inbound traffic: once applied, only what a rule allows gets through. Outbound stays open.

Yield the new resources from the Stack and output the LB address instead of the Server’s:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Hetzner from "alchemy/Hetzner";
import * as Output from "alchemy/Output";
import * as Effect from "effect/Effect";
import Api from "./src/api.ts";
import { Box } from "./src/server.ts";
import { Box, Edge, Wall } from "./src/server.ts";
// ...
Effect.gen(function* () {
const server = yield* Box;
const lb = yield* Edge;
yield* Wall;
const api = yield* Api;
return {
ipv4: server.ipv4,
url: api.url,
url: Output.interpolate`http://${lb.ipv4}`,
};
}),

Output.interpolate builds a string from resource outputs — the LB’s IP isn’t known until it’s created, so the template resolves after deploy.

Terminal window
bun alchemy deploy
Plan: 3 to create, 1 to update

+ net (Hetzner.Network)
+ edge (Hetzner.LoadBalancer)
+ wall (Hetzner.Firewall)
~ box (Hetzner.Server)

Proceed?
◉ Yes ○ No
 net (Hetzner.Network) created
 box (Hetzner.Server) updated
 edge (Hetzner.LoadBalancer) created
 wall (Hetzner.Firewall) created
{
  ipv4: "203.0.113.10",
  url: "http://198.51.100.7",
}

The app now answers on the Load Balancer:

Terminal window
curl http://198.51.100.7/hello.txt
# → Hello, Volume!

And the direct route is gone — the Firewall drops it:

Terminal window
curl --max-time 5 http://203.0.113.10:3000/hello.txt
# → curl: (28) Connection timed out

You’re done — tear everything down so the hourly billing stops:

Terminal window
bun alchemy destroy

Alchemy deletes everything in reverse dependency order — Service, Load Balancer, Firewall, Volume, Server, Network, SSH key.

Over four parts you built a complete, production-shaped deployment:

  • A Server provisioned in seconds, with an Alchemy-managed deploy key
  • An HTTP Service bundled, shipped over SSH, and supervised by systemd — updated only when its code hash changes
  • A Volume mounted at /data whose contents outlive deploys
  • A private Network, a health-checked Load Balancer, and a default-deny Firewall
  • Services — background workers, environment variables, multiple Services per Server.
  • Servers — SSH access, snapshots, placement groups, cloud-init.
  • DNS — zones and records for your own domain.
  • Testing — deploy this stack from an integration test and drive it over HTTP.
  • CI — run alchemy deploy from GitHub Actions with HCLOUD_TOKEN.