1import * as Hetzner from "alchemy/Hetzner";
2import * as Effect from "effect/Effect";
3
4export const VOLUME_PATH = "/data";
5export const MARKER_FILE = `${VOLUME_PATH}/hello.txt`;
6export const MARKER = "hello-from-worker";
7export const API_PORT = 3000;
8
9/**
10 * Private Network both the Server and Load Balancer attach to.
11 * nbg1 lives in `eu-central`.
12 */
13export const Net = Hetzner.Network("Network", {
14 ipRange: "10.42.0.0/16",
15 subnets: [
16 {
17 type: "cloud",
18 ipRange: "10.42.1.0/24",
19 networkZone: "eu-central",
20 },
21 ],
22});
23
24/**
25 * The box both Services share. Alchemy injects a deploy SSH key so
26 * `Hetzner.Service` can copy the bundled unit over SSH.
27 */
28export const Box = Hetzner.Server(
29 "Box",
30 Effect.gen(function* () {
31 const network = yield* Net;
32 return {
33 serverType: "cpx12",
34 image: "ubuntu-24.04",
35 location: "nbg1",
36 networks: [network],
37 };
38 }),
39);
40
41/** Shared Volume both Services mount at {@link VOLUME_PATH}. */
42export const Data = Hetzner.Volume("Data", {
43 size: 10,
44 format: "ext4",
45 location: "nbg1",
46});
47
48export const Wall = Hetzner.Firewall(
49 "Wall",
50 Effect.gen(function* () {
51 const server = yield* Box;
52 return {
53 applyTo: [server],
54 rules: [
55 {
56 direction: "in" as const,
57 protocol: "tcp" as const,
58 port: "22",
59 sourceIps: ["0.0.0.0/0", "::/0"],
60 description: "ssh",
61 },
62 {
63 direction: "in" as const,
64 protocol: "tcp" as const,
65 port: String(API_PORT),
66 sourceIps: ["0.0.0.0/0", "::/0"],
67 description: "api",
68 },
69 ],
70 };
71 }),
72);
73
74export const Edge = Hetzner.LoadBalancer(
75 "Edge",
76 Effect.gen(function* () {
77 const server = yield* Box;
78 const network = yield* Net;
79 return {
80 location: "nbg1",
81 loadBalancerType: "lb11",
82 networks: [network],
83 services: [
84 {
85 protocol: "http" as const,
86 listenPort: 80,
87 destinationPort: API_PORT,
88 healthCheck: {
89 protocol: "http" as const,
90 port: API_PORT,
91 interval: 3,
92 timeout: 2,
93 retries: 2,
94 http: { path: "/health" },
95 },
96 },
97 ],
98 targets: [{ type: "server" as const, server, usePrivateIp: true }],
99 };
100 }),
101);
102
103/**
104 * Optional DNS: yielded from the stack only when `HETZNER_ZONE` is set
105 * (a real apex). Changing the name replaces the Zone.
106 */
107export const DnsZone = Hetzner.Zone("AppZone", {
108 name: process.env.HETZNER_ZONE,
109 ttl: 300,
110});
111
112export const AppRecord = Hetzner.RecordSet(
113 "App",
114 Effect.flatMap(Edge, (lb) =>
115 Effect.map(DnsZone, (zone) => ({
116 zone,
117 name: "app",
118 type: "A" as const,
119 records: [{ value: lb.ipv4.as<string>() }],
120 ttl: 300,
121 })),
122 ),
123);