1---
2title: "Part 4: Networking & Load Balancing"
3description: Put your Service behind a managed Load Balancer on a private network, and lock the Server down with a Firewall.
4sidebar:
5 order: 4
6---
7
8import Terminal from "../../../../components/Terminal.astro";
9import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
10
11Right now your Service is served straight off the Server's public
12IP, and every port on the Server is open to the internet. In this
13final part you'll make it production-shaped: a **private Network**
14between 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]
18The `lb11` Load Balancer costs about €5.39/month, billed hourly like
19everything else. The [cleanup step](#clean-up) at the end tears it
20all down.
21:::
22
23## Create a private Network
24
25Add a Network to `src/server.ts` and attach the Server to it:
26
27```diff lang="typescript"
28// src/server.ts
29import * 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
38export const Box = Hetzner.Server("box", {
39 serverType: "cx22",
40 image: "ubuntu-24.04",
41 location: "nbg1",
42 sshKeys: [Key],
43+ networks: [Net],
44});
45
46export const Data = Hetzner.Volume("data", { /* ... */ });
47```
48
49`nbg1` lives in the `eu-central` network zone. Passing `Net` — a
50module-scope resource declaration — directly in `networks` is
51enough: resource-valued props accept the resource or an Effect
52producing it, and Alchemy resolves the reference and orders the
53deploy.
54
55## Add a Load Balancer
56
57Declare the Load Balancer in `src/server.ts`, targeting the Server
58over 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
85The LB listens on port 80 and forwards to port 3000 on the Server,
86reaching it through the private Network (`usePrivateIp: true`). Its
87health check polls the same `/health` route the deploy already
88uses — an unhealthy Service is pulled out of rotation automatically.
89
90## Lock down the Server
91
92With traffic flowing through the private network, close the public
93app port. Apply a Firewall to the Server that allows SSH from
94anywhere 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
119A Hetzner Firewall is default-deny for inbound traffic: once
120applied, only what a rule allows gets through. Outbound stays open.
121
122## Surface the Load Balancer URL
123
124Yield the new resources from the Stack and output the LB address
125instead of the Server's:
126
127```diff lang="typescript"
128// alchemy.run.ts
129import * as Alchemy from "alchemy";
130import * as Hetzner from "alchemy/Hetzner";
131+import * as Output from "alchemy/Output";
132import * as Effect from "effect/Effect";
133import 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
153LB's IP isn't known until it's created, so the template resolves
154after 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
180Proceed?
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
193The app now answers on the Load Balancer:
194
195```sh
196curl http://198.51.100.7/hello.txt
197# → Hello, Volume!
198```
199
200And the direct route is gone — the Firewall drops it:
201
202```sh
203curl --max-time 5 http://203.0.113.10:3000/hello.txt
204# → curl: (28) Connection timed out
205```
206
207:::tip[Your own domain?]
208Hetzner DNS is part of the provider too: declare a `Hetzner.Zone`
209and 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
217You'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
234Alchemy deletes everything in reverse dependency order — Service,
235Load Balancer, Firewall, Volume, Server, Network, SSH key.
236
237## Recap
238
239Over 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`.