| 1 | --- |
| 2 | title: "Part 4: Networking & Load Balancing" |
| 3 | description: Put your Service behind a managed Load Balancer on a private network, and lock the Server down with a Firewall. |
| 4 | sidebar: |
| 5 | order: 4 |
| 6 | --- |
| 7 | |
| 8 | import Terminal from "../../../../components/Terminal.astro"; |
| 9 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 10 | |
| 11 | Right now your Service is served straight off the Server's public |
| 12 | IP, and every port on the Server is open to the internet. In this |
| 13 | final part you'll make it production-shaped: a **private Network** |
| 14 | between the machines, a managed **Load Balancer** in front, and a |
| 15 | **Firewall** that closes the app port to everyone but the LB. |
| 16 | |
| 17 | :::note[Costs] |
| 18 | The `lb11` Load Balancer costs about €5.39/month, billed hourly like |
| 19 | everything else. The [cleanup step](#clean-up) at the end tears it |
| 20 | all down. |
| 21 | ::: |
| 22 | |
| 23 | ## Create a private Network |
| 24 | |
| 25 | Add a Network to `src/server.ts` and attach the Server to it: |
| 26 | |
| 27 | ```diff lang="typescript" |
| 28 | // src/server.ts |
| 29 | import * as Hetzner from "alchemy/Hetzner"; |
| 30 | |
| 31 | +export const Net = Hetzner.Network("net", { |
| 32 | + ipRange: "10.0.0.0/16", |
| 33 | + subnets: [ |
| 34 | + { type: "cloud", networkZone: "eu-central", ipRange: "10.0.1.0/24" }, |
| 35 | + ], |
| 36 | +}); |
| 37 | |
| 38 | export const Box = Hetzner.Server("box", { |
| 39 | serverType: "cx22", |
| 40 | image: "ubuntu-24.04", |
| 41 | location: "nbg1", |
| 42 | sshKeys: [Key], |
| 43 | + networks: [Net], |
| 44 | }); |
| 45 | |
| 46 | export const Data = Hetzner.Volume("data", { /* ... */ }); |
| 47 | ``` |
| 48 | |
| 49 | `nbg1` lives in the `eu-central` network zone. Passing `Net` — a |
| 50 | module-scope resource declaration — directly in `networks` is |
| 51 | enough: resource-valued props accept the resource or an Effect |
| 52 | producing it, and Alchemy resolves the reference and orders the |
| 53 | deploy. |
| 54 | |
| 55 | ## Add a Load Balancer |
| 56 | |
| 57 | Declare the Load Balancer in `src/server.ts`, targeting the Server |
| 58 | over its **private** IP: |
| 59 | |
| 60 | ```diff lang="typescript" |
| 61 | // src/server.ts |
| 62 | +export const Edge = Hetzner.LoadBalancer("edge", { |
| 63 | + location: "nbg1", |
| 64 | + loadBalancerType: "lb11", |
| 65 | + networks: [Net], |
| 66 | + targets: [{ type: "server", server: Box, usePrivateIp: true }], |
| 67 | + services: [ |
| 68 | + { |
| 69 | + protocol: "http", |
| 70 | + listenPort: 80, |
| 71 | + destinationPort: 3000, |
| 72 | + healthCheck: { |
| 73 | + protocol: "http", |
| 74 | + port: 3000, |
| 75 | + interval: 3, |
| 76 | + timeout: 2, |
| 77 | + retries: 2, |
| 78 | + http: { path: "/health" }, |
| 79 | + }, |
| 80 | + }, |
| 81 | + ], |
| 82 | +}); |
| 83 | ``` |
| 84 | |
| 85 | The LB listens on port 80 and forwards to port 3000 on the Server, |
| 86 | reaching it through the private Network (`usePrivateIp: true`). Its |
| 87 | health check polls the same `/health` route the deploy already |
| 88 | uses — an unhealthy Service is pulled out of rotation automatically. |
| 89 | |
| 90 | ## Lock down the Server |
| 91 | |
| 92 | With traffic flowing through the private network, close the public |
| 93 | app port. Apply a Firewall to the Server that allows SSH from |
| 94 | anywhere but port 3000 only from inside the Network: |
| 95 | |
| 96 | ```diff lang="typescript" |
| 97 | // src/server.ts |
| 98 | +export const Wall = Hetzner.Firewall("wall", { |
| 99 | + applyTo: [Box], |
| 100 | + rules: [ |
| 101 | + { |
| 102 | + direction: "in", |
| 103 | + protocol: "tcp", |
| 104 | + port: "22", |
| 105 | + sourceIps: ["0.0.0.0/0", "::/0"], |
| 106 | + description: "ssh", |
| 107 | + }, |
| 108 | + { |
| 109 | + direction: "in", |
| 110 | + protocol: "tcp", |
| 111 | + port: "3000", |
| 112 | + sourceIps: ["10.0.0.0/16"], |
| 113 | + description: "api via private net", |
| 114 | + }, |
| 115 | + ], |
| 116 | +}); |
| 117 | ``` |
| 118 | |
| 119 | A Hetzner Firewall is default-deny for inbound traffic: once |
| 120 | applied, only what a rule allows gets through. Outbound stays open. |
| 121 | |
| 122 | ## Surface the Load Balancer URL |
| 123 | |
| 124 | Yield the new resources from the Stack and output the LB address |
| 125 | instead of the Server's: |
| 126 | |
| 127 | ```diff lang="typescript" |
| 128 | // alchemy.run.ts |
| 129 | import * as Alchemy from "alchemy"; |
| 130 | import * as Hetzner from "alchemy/Hetzner"; |
| 131 | +import * as Output from "alchemy/Output"; |
| 132 | import * as Effect from "effect/Effect"; |
| 133 | import Api from "./src/api.ts"; |
| 134 | -import { Box } from "./src/server.ts"; |
| 135 | +import { Box, Edge, Wall } from "./src/server.ts"; |
| 136 | |
| 137 | // ... |
| 138 | Effect.gen(function* () { |
| 139 | const server = yield* Box; |
| 140 | + const lb = yield* Edge; |
| 141 | + yield* Wall; |
| 142 | const api = yield* Api; |
| 143 | |
| 144 | return { |
| 145 | ipv4: server.ipv4, |
| 146 | - url: api.url, |
| 147 | + url: Output.interpolate`http://${lb.ipv4}`, |
| 148 | }; |
| 149 | }), |
| 150 | ``` |
| 151 | |
| 152 | `Output.interpolate` builds a string from resource outputs — the |
| 153 | LB's IP isn't known until it's created, so the template resolves |
| 154 | after deploy. |
| 155 | |
| 156 | ## Deploy |
| 157 | |
| 158 | <Tabs syncKey="pkgManager"> |
| 159 | <TabItem label="bun" icon="bun"> |
| 160 | <Code code="bun alchemy deploy" lang="sh" /> |
| 161 | </TabItem> |
| 162 | <TabItem label="npm" icon="npm"> |
| 163 | <Code code="npm run alchemy deploy" lang="sh" /> |
| 164 | </TabItem> |
| 165 | <TabItem label="pnpm" icon="pnpm"> |
| 166 | <Code code="pnpm alchemy deploy" lang="sh" /> |
| 167 | </TabItem> |
| 168 | <TabItem label="yarn" icon="seti:yarn"> |
| 169 | <Code code="yarn alchemy deploy" lang="sh" /> |
| 170 | </TabItem> |
| 171 | </Tabs> |
| 172 | |
| 173 | <Terminal content={`[u]Plan[/u]: [g]3 to create[/g], [y]1 to update[/y] |
| 174 | |
| 175 | [g]+[/g] [b]net[/b] [d](Hetzner.Network)[/d] |
| 176 | [g]+[/g] [b]edge[/b] [d](Hetzner.LoadBalancer)[/d] |
| 177 | [g]+[/g] [b]wall[/b] [d](Hetzner.Firewall)[/d] |
| 178 | [y]~[/y] [b]box[/b] [d](Hetzner.Server)[/d] |
| 179 | |
| 180 | Proceed? |
| 181 | ◉ Yes ○ No |
| 182 | [g]✓[/g] [b]net[/b] [d](Hetzner.Network)[/d] [g]created[/g] |
| 183 | [g]✓[/g] [b]box[/b] [d](Hetzner.Server)[/d] [y]updated[/y] |
| 184 | [g]✓[/g] [b]edge[/b] [d](Hetzner.LoadBalancer)[/d] [g]created[/g] |
| 185 | [g]✓[/g] [b]wall[/b] [d](Hetzner.Firewall)[/d] [g]created[/g] |
| 186 | { |
| 187 | [s2]ipv4: "203.0.113.10", |
| 188 | [s2]url: "http://198.51.100.7", |
| 189 | }`} /> |
| 190 | |
| 191 | ## Try it out |
| 192 | |
| 193 | The app now answers on the Load Balancer: |
| 194 | |
| 195 | ```sh |
| 196 | curl http://198.51.100.7/hello.txt |
| 197 | # → Hello, Volume! |
| 198 | ``` |
| 199 | |
| 200 | And the direct route is gone — the Firewall drops it: |
| 201 | |
| 202 | ```sh |
| 203 | curl --max-time 5 http://203.0.113.10:3000/hello.txt |
| 204 | # → curl: (28) Connection timed out |
| 205 | ``` |
| 206 | |
| 207 | :::tip[Your own domain?] |
| 208 | Hetzner DNS is part of the provider too: declare a `Hetzner.Zone` |
| 209 | and a `Hetzner.RecordSet` pointing at `lb.ipv4`, and add a managed |
| 210 | `Hetzner.Certificate` to terminate TLS on the Load Balancer. See |
| 211 | [DNS](/hetzner/networking/dns) and |
| 212 | [Networking](/hetzner/networking#load-balancer). |
| 213 | ::: |
| 214 | |
| 215 | ## Clean up |
| 216 | |
| 217 | You're done — tear everything down so the hourly billing stops: |
| 218 | |
| 219 | <Tabs syncKey="pkgManager"> |
| 220 | <TabItem label="bun" icon="bun"> |
| 221 | <Code code="bun alchemy destroy" lang="sh" /> |
| 222 | </TabItem> |
| 223 | <TabItem label="npm" icon="npm"> |
| 224 | <Code code="npm run alchemy destroy" lang="sh" /> |
| 225 | </TabItem> |
| 226 | <TabItem label="pnpm" icon="pnpm"> |
| 227 | <Code code="pnpm alchemy destroy" lang="sh" /> |
| 228 | </TabItem> |
| 229 | <TabItem label="yarn" icon="seti:yarn"> |
| 230 | <Code code="yarn alchemy destroy" lang="sh" /> |
| 231 | </TabItem> |
| 232 | </Tabs> |
| 233 | |
| 234 | Alchemy deletes everything in reverse dependency order — Service, |
| 235 | Load Balancer, Firewall, Volume, Server, Network, SSH key. |
| 236 | |
| 237 | ## Recap |
| 238 | |
| 239 | Over four parts you built a complete, production-shaped deployment: |
| 240 | |
| 241 | - A Server provisioned in seconds, with an Alchemy-managed deploy |
| 242 | key |
| 243 | - An HTTP Service bundled, shipped over SSH, and supervised by |
| 244 | systemd — updated only when its code hash changes |
| 245 | - A Volume mounted at `/data` whose contents outlive deploys |
| 246 | - A private Network, a health-checked Load Balancer, and a |
| 247 | default-deny Firewall |
| 248 | |
| 249 | ## Where next |
| 250 | |
| 251 | - [Services](/hetzner/compute/services) — background workers, |
| 252 | environment variables, multiple Services per Server. |
| 253 | - [Servers](/hetzner/compute/servers) — SSH access, snapshots, |
| 254 | placement groups, cloud-init. |
| 255 | - [DNS](/hetzner/networking/dns) — zones and records for your own |
| 256 | domain. |
| 257 | - [Testing](/testing) — deploy this stack from an integration test |
| 258 | and drive it over HTTP. |
| 259 | - [CI](/environments/ci) — run `alchemy deploy` from GitHub Actions |
| 260 | with `HCLOUD_TOKEN`. |