Compare commits

...

2 Commits

Author SHA1 Message Date
7b42f2e3bd docker-compose resource definitions for web servers 2025-12-29 02:36:01 +01:00
80b58a9f3e hetzner haproxy, varnish & web setup 2025-12-29 02:34:03 +01:00
18 changed files with 2533 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
version: '3'
services:
traefik:
image: "traefik:latest"
container_name: traefik
restart: unless-stopped
# PORTS
ports:
# HTTP entrypoint
# Exposed on all external addresses (0.0.0.0:80)
- "80:80"
# Traefik API & Dashboard
# Accessible on http://<host>:8080
- "8080:8080"
# COMMAND (STATIC CONFIGURATION)
command:
# Enable Traefik API & Dashboard
- "--api.dashboard=true"
- "--api.insecure=true"
# Log settings
- "--log.level=INFO"
- "--accesslog=true"
# EntryPoints
- "--entrypoints.web.address=:80"
- "--entrypoints.traefik.address=:8080"
# Docker provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
# Optional: file provider for dynamic config
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
# Global settings
- "--global.checknewversion=true"
- "--global.sendanonymoususage=false"
# VOLUMES
volumes:
# Docker socket (required for Docker provider)
- /var/run/docker.sock:/var/run/docker.sock:ro
# Dynamic configuration directory (middlewares, routers, TLS, etc.)
- ./dynamic:/etc/traefik/dynamic:ro
# Logs (optional)
- ./logs:/logs
# NETWORKS
networks:
- traefik
# LABELS (OPTIONAL SELF-ROUTING)
labels:
# Enable Traefik for this container
- "traefik.enable=true"
# Router for dashboard (via Traefik itself)
- "traefik.http.routers.traefik.rule=Host(`traefik.localhost`)"
- "traefik.http.routers.traefik.entrypoints=web"
- "traefik.http.routers.traefik.service=api@internal"
# NETWORK DEFINITIONS
networks:
traefik:
name: traefik
driver: bridge

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
########################################
# CONFIG
########################################
COMPOSE_FILE_NAME="docker-compose.yml"
########################################
# ARGUMENT CHECK
########################################
if [[ $# -ne 1 ]]; then
echo "Usage: $0 {up|down}"
exit 1
fi
ACTION="$1"
if [[ "$ACTION" != "up" && "$ACTION" != "down" ]]; then
echo "Invalid action: $ACTION"
echo "Allowed actions: up, down"
exit 1
fi
########################################
# SAVE STARTING DIRECTORY
########################################
START_DIR="$(pwd)"
########################################
# FIND COMPOSE FILES
########################################
mapfile -t COMPOSE_DIRS < <(
find . -type f -name "$COMPOSE_FILE_NAME" -print0 \
| xargs -0 -n1 dirname | sort
)
########################################
# LOOP THROUGH DIRECTORIES
########################################
for DIR in "${COMPOSE_DIRS[@]}"; do
echo "----------------------------------------"
echo "Processing: $DIR"
echo "Action: docker-compose $ACTION"
echo "----------------------------------------"
cd "$DIR"
if [[ "$ACTION" == "up" ]]; then
docker-compose up -d
else
docker-compose down
fi
cd "$START_DIR"
done
echo "========================================"
echo "Completed docker-compose $ACTION for all stacks"
echo "========================================"

View File

@@ -0,0 +1,28 @@
version: '3'
services:
k9e:
image: kevinmidboe/k9e.no:latest
container_name: k9e
restart: unless-stopped
# NETWORK
networks:
- traefik
# TRAEFIK LABELS
labels:
# Enable Traefik for this container
- "traefik.enable=true"
# Router definition
- "traefik.http.routers.k9e.rule=Host(`k9e.no`)"
- "traefik.http.routers.k9e.entrypoints=web"
# Service definition
- "traefik.http.services.k9e.loadbalancer.server.port=80"
# NETWORK DEFINITIONS
networks:
traefik:
external: true

View File

@@ -0,0 +1,28 @@
version: '3'
services:
planetposen-original:
image: kevinmidboe/planetposen-original:latest
container_name: planetposen
restart: unless-stopped
# NETWORK
networks:
- traefik
# TRAEFIK LABELS
labels:
# Enable Traefik for this container
- "traefik.enable=true"
# Router definition
- "traefik.http.routers.planetposen-original.rule=Host(`planetposen.no`)"
- "traefik.http.routers.planetposen-original.entrypoints=web"
# Service definition
- "traefik.http.services.planetposen-original.loadbalancer.server.port=80"
# NETWORK DEFINITIONS
networks:
traefik:
external: true

View File

@@ -0,0 +1,29 @@
version: '3'
services:
whoami:
image: traefik/whoami
container_name: whoami
restart: unless-stopped
# NETWORK
networks:
- traefik
# TRAEFIK LABELS
labels:
# Enable Traefik for this container
- "traefik.enable=true"
# Router definition
- "traefik.http.routers.whoami.rule=Host(`whoami.example.com`)"
- "traefik.http.routers.whoami.entrypoints=web"
# Service definition
- "traefik.http.services.whoami.loadbalancer.server.port=80"
# NETWORK DEFINITIONS
networks:
traefik:
external: true

2
hetzner-pulumi/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/bin/
/node_modules/

View File

@@ -0,0 +1,6 @@
name: hetzner-pulumi
description: Manages schleppe ha project hetzner resources
runtime:
name: nodejs
options:
packagemanager: yarn

54
hetzner-pulumi/index.ts Normal file
View File

@@ -0,0 +1,54 @@
import {
subNetwork,
regionalNetwork,
} from "./resources/network";
import { genServer } from "./resources/compute";
import {
VmSize,
OS,
NetworkRegion,
NetworkRole,
ServerLocations,
} from "./resources/types";
const eu = regionalNetwork("ha", "10.24.0.0/18", NetworkRegion.eu);
const usEast = regionalNetwork("ha", "10.25.0.0/18", NetworkRegion.usEast);
const network = {
eu: {
lb: subNetwork(eu, NetworkRole.lb, NetworkRegion.eu, "10.24.1.0/24"),
cache: subNetwork(eu, NetworkRole.cache, NetworkRegion.eu, "10.24.2.0/24"),
web: subNetwork(eu, NetworkRole.web, NetworkRegion.eu, "10.24.3.0/24"),
// db: subNetwork(eu, NetworkRole.db, "10.24.4.0/24")
},
us: {
lb: subNetwork(usEast, NetworkRole.lb, NetworkRegion.usEast, "10.25.1.0/24"),
web: subNetwork(usEast, NetworkRole.web, NetworkRegion.usEast, "10.25.2.0/24"),
},
};
const hel1 = ServerLocations.helsinki;
const hil = ServerLocations.hillsboro;
const haproxyEU1 = genServer("haproxy-1", VmSize.small, OS.debian, hel1, network.eu.lb);
const haproxyEU2 = genServer("haproxy-2", VmSize.small, OS.debian, hel1, network.eu.lb);
const haproxyUS1 = genServer("haproxy-1", VmSize.small, OS.debian, hil, network.us.lb);
const haproxyCache1 = genServer("varnish-1", VmSize.small, OS.debian, hel1, network.eu.cache);
const haproxyCache2 = genServer("varnish-2", VmSize.small, OS.debian, hel1, network.eu.cache);
// const varnishUS = genServer(2, 'varnish', VmSize.small, OS.debian, hel1, network.us.cache)
export const servers = [
haproxyEU1, haproxyEU2, haproxyUS1, haproxyCache1, haproxyCache2
];
export const networks = [
eu,
usEast,
network.eu.lb,
network.eu.cache,
network.eu.web,
network.us.lb,
network.us.web,
];

View File

@@ -0,0 +1,14 @@
{
"name": "hetzner-pulumi",
"main": "index.ts",
"devDependencies": {
"@types/node": "^18",
"typescript": "^5.0.0"
},
"dependencies": {
"@pulumi/hcloud": "^1.29.0",
"@pulumi/pulumi": "^3.213.0",
"@pulumi/random": "^4.18.4",
"zod": "^4.2.1"
}
}

View File

@@ -0,0 +1,52 @@
import * as pulumi from "@pulumi/pulumi";
import * as hcloud from "@pulumi/hcloud";
import * as random from "@pulumi/random";
import { config } from './config';
import { getCheapestServerType } from './utils';
import { VmSize, OS, ServerLocations } from "./types";
// “Tag” servers using labels. Hetzner firewalls can target servers by label selectors. :contentReference[oaicite:2]{index=2}
const serverLabels = {
app: "demo",
role: "web",
env: pulumi.getStack(),
};
/*
function getSshPublicKey(): hcloud.SshKey {
const sshPublicKey = config.require("sshPublicKey");
return sshKey;
}
*/
const sshPublicKey = config.require("sshPublicKey");
const sshKey = new hcloud.SshKey("ssh-key", {
name: `pulumi-${pulumi.getStack()}-ssh`,
publicKey: sshPublicKey,
});
export function genServer(
name: string,
size: VmSize,
os: OS = OS.debian,
location: ServerLocations,
network: hcloud.NetworkSubnet
): hcloud.Server {
const ceap = getCheapestServerType('eu');
const hexId = new random.RandomId(`${name}-${location}`, {
byteLength: 2, // 2 bytes = 4 hex characters
});
name = `${name}-${location}`
return new hcloud.Server(name, {
name,
image: os,
serverType: ceap,
location,
networks: [network],
sshKeys: [sshKey.name],
labels: serverLabels
})
}

View File

@@ -0,0 +1,16 @@
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const variables = {
osImage: config.get("image") || "debian-11",
machineType: config.get("serverType") || "f1-micro",
machineLocation: config.get("location") || "hel1",
instanceTag: config.get("instanceTag") || "webserver",
servicePort: config.get("servicePort") || "80"
}
export {
variables,
config
}

View File

@@ -0,0 +1,95 @@
import * as pulumi from "@pulumi/pulumi";
import * as hcloud from "@pulumi/hcloud";
import type { NetworkRegion } from "./types";
// Required
// make sure to have regional parent networks
const networkName = (name: string, region: NetworkRegion) =>
`${name}-net-${region}`;
export function regionalNetwork(
prefix: string,
cidr: string,
region: NetworkRegion,
) {
const name = networkName(prefix, region);
const parentNetworkRange = 8;
const [ip, _] = cidr.split("/");
const net = new hcloud.Network(name, {
ipRange: `${ip}/${parentNetworkRange}`,
labels: {
region,
hiearchy: "parent",
},
});
return net;
}
export function subNetwork(
parentNetwork: hcloud.Network,
prefix: string,
region: NetworkRegion,
cidr: string,
): hcloud.NetworkSubnet {
const name = `${prefix}-subnet-${region}`;
const net = new hcloud.NetworkSubnet(name, {
networkId: parentNetwork.id.apply(id => Number(id)),
type: "cloud",
networkZone: "eu-central",
ipRange: cidr,
});
return net;
}
export const allowHttp = new hcloud.Firewall("allow-http", {
name: "allow-http",
applyTos: [
{
labelSelector: `role=load-balancer,env=${pulumi.getStack()}`,
},
],
rules: [
{
direction: "in",
protocol: "tcp",
port: "80",
sourceIps: ["0.0.0.0/0", "::/0"],
description: "Allow HTTP",
},
{
direction: "in",
protocol: "tcp",
port: "443",
sourceIps: ["0.0.0.0/0", "::/0"],
description: "Allow HTTPS",
},
{
direction: "in",
protocol: "udp",
port: "443",
sourceIps: ["0.0.0.0/0", "::/0"],
description: "Allow QUIC",
},
],
});
export const allowSSH = new hcloud.Firewall("allow-ssh", {
name: "allow-ssh",
rules: [
{
direction: "in",
protocol: "tcp",
port: "22",
sourceIps: ["127.0.0.0/24"],
description: "Allow SSH from approved CIDRs only",
},
],
});

View File

@@ -0,0 +1,2 @@
export * from "./network";
export * from "./server";

View File

@@ -0,0 +1,12 @@
export enum NetworkRegion {
eu = "eu-central",
usWest = "us-west",
usEast = "us-east",
}
export enum NetworkRole {
lb = "load-balancer",
cache = "varnish-cache",
web = "webserver",
db = "database",
}

View File

@@ -0,0 +1,19 @@
export enum VmSize {
small = "small",
medium = "medium",
large = "large",
}
export enum OS {
debian = "debian",
ubuntu = "ubuntu",
}
export enum ServerLocations {
helsinki = "hel1",
falkenstein = "fsn1",
nuremberg = "nbg1",
hillsboro = "hil",
ashburn = "ash",
sinapore = "sig",
}

View File

@@ -0,0 +1,105 @@
import * as pulumi from "@pulumi/pulumi";
import { z } from "zod";
import * as crypto from "node:crypto";
/**
* Region abstraction exposed to users
*/
export type PricingRegion = "eu" | "us" | "ap";
/**
* Hetzner region → locations mapping
*/
const regionToLocations: Record<PricingRegion, string[]> = {
eu: ["nbg1", "fsn1", "hel1"],
us: ["ash", "hil"],
ap: ["sin"],
};
const HCLOUD_API = "https://api.hetzner.cloud/v1";
/**
* Runtime validation for Hetzner /server_types response
*/
const serverTypesResponseSchema = z.object({
server_types: z.array(
z.object({
name: z.string(),
deprecated: z.boolean().optional(),
prices: z.array(
z.object({
location: z.string(),
price_monthly: z.object({
gross: z.string(),
}),
price_hourly: z.object({
gross: z.string(),
}),
}),
),
}),
),
});
/**
* Returns the cheapest available server type name
* for a given abstract region (eu | us | ap).
*
* Pricing basis: monthly gross
*/
export function getCheapestServerType(
region: PricingRegion,
): pulumi.Output<string> {
const locations = regionToLocations[region];
const hcloudCfg = new pulumi.Config("hcloud");
const token = hcloudCfg.requireSecret("token");
return pulumi.all([token]).apply(async ([t]) => {
const res = await fetch(`${HCLOUD_API}/server_types`, {
headers: { Authorization: `Bearer ${t}` },
});
if (!res.ok) {
throw new pulumi.RunError(
`Hetzner API error: ${res.status} ${res.statusText}`,
);
}
const json = await res.json();
const parsed = serverTypesResponseSchema.safeParse(json);
if (!parsed.success) {
const hash = crypto
.createHash("sha256")
.update(JSON.stringify(json))
.digest("hex")
.slice(0, 12);
throw new pulumi.RunError(
`Unexpected Hetzner /server_types payload (sha256:${hash})`,
);
}
const cheapest = parsed.data.server_types
.filter((st) => st.deprecated !== true)
.flatMap((st) =>
st.prices
.filter((p) => locations.includes(p.location))
.map((p) => ({
name: st.name,
price: Number.parseFloat(p.price_hourly.gross),
})),
)
.filter((x) => Number.isFinite(x.price))
.sort((a, b) => a.price - b.price)[0];
if (!cheapest) {
throw new pulumi.RunError(
`No priced server types found for region=${region}`,
);
}
return cheapest.name;
});
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2020",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}

1917
hetzner-pulumi/yarn.lock Normal file

File diff suppressed because it is too large Load Diff