Pulumi code for my server setup
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Switch from Docker Swarm to pulumi

+632 -19
+11
.env.example
··· 1 + CF_API_KEY= 2 + IPINFO_APIKEY= 3 + VNC_PASSWORD= 4 + QBITTORRENT_USERNAME= 5 + QBITTORRENT_PASSWORD= 6 + PLEX_TOKEN= 7 + DISCORD_AUTO_LANGUAGES_WEBHOOK= 8 + EMAIL= 9 + PUID= 10 + PGID= 11 + TZ=
+3
.gitignore
··· 1 + node_modules/ 2 + 3 + .env
+2
Pulumi.prod.yaml
··· 1 + config: 2 + docker:host: ssh://haring
+11
Pulumi.yaml
··· 1 + name: media-server-stack 2 + runtime: 3 + name: nodejs 4 + options: 5 + nodeargs: "--loader ts-node/esm --no-warnings --experimental-specifier-resolution=node" 6 + typescript: true 7 + description: Pulumi definitions for my media server 8 + config: 9 + pulumi:tags: 10 + value: 11 + pulumi:template: https://www.pulumi.com/ai/api/project/836ba65a-7cc5-4519-930b-2e6ec54cc841.zip
+6 -19
README.md
··· 1 - # Media Server Stack 1 + # Media Server 2 2 3 - My media server is hosted with Docker Swarm. This repo consists of a Docker Compose file written in Jsonnet and compiled to JSON (works with any YAML parser because JSON is a subset of YAML). 3 + My single node media server. Used to be Docker Swarm (in the jsonnet folder), now just Docker containers orchestrated using Pulumi. 4 4 5 - My main server is called Haring (following my device hostname naming scheme of Dutch food). 5 + # Setup 6 6 7 - Compile and copy to clipboard in WSL: 8 - ```bash 9 - jsonnet haring.jsonnet -o haring.json && win32yank.exe -i < haring.json 10 - ``` 11 - or under Wayland 12 - ```bash 13 - jsonnet haring.jsonnet -o haring.json && wl-copy < haring.json 14 - ``` 15 - or under Xorg 16 - ```bash 17 - jsonnet haring.jsonnet -o haring.json && xclip < haring.json 18 - ``` 7 + First change the required variables in .env, then 19 8 20 - ## Setup 21 9 ```bash 22 - docker swarm init 23 - docker stack deploy -c portainer-agent-stack.yml portainer 10 + bun install 11 + pulumi up 24 12 ``` 25 - then deploy the media server stack by copying the contents of `haring.json` to Portainer.
bun.lockb

This is a binary file and will not be displayed.

haring.json jsonnet/haring.json
haring.jsonnet jsonnet/haring.jsonnet
+25
jsonnet/README.md
··· 1 + # Media Server Stack 2 + 3 + My media server is hosted with Docker Swarm. This repo consists of a Docker Compose file written in Jsonnet and compiled to JSON (works with any YAML parser because JSON is a subset of YAML). 4 + 5 + My main server is called Haring (following my device hostname naming scheme of Dutch food). 6 + 7 + Compile and copy to clipboard in WSL: 8 + ```bash 9 + jsonnet haring.jsonnet -o haring.json && win32yank.exe -i < haring.json 10 + ``` 11 + or under Wayland 12 + ```bash 13 + jsonnet haring.jsonnet -o haring.json && wl-copy < haring.json 14 + ``` 15 + or under Xorg 16 + ```bash 17 + jsonnet haring.jsonnet -o haring.json && xclip < haring.json 18 + ``` 19 + 20 + ## Setup 21 + ```bash 22 + docker swarm init 23 + docker stack deploy -c portainer-agent-stack.yml portainer 24 + ``` 25 + then deploy the media server stack by copying the contents of `haring.json` to Portainer.
+19
package.json
··· 1 + { 2 + "name": "media-server-stack", 3 + "description": "Pulumi definitions for my media server", 4 + "main": "src/index.ts", 5 + "type": "module", 6 + "scripts": {}, 7 + "author": "", 8 + "dependencies": { 9 + "@pulumi/command": "^0.9.2", 10 + "@pulumi/docker": "4.5.3", 11 + "@pulumi/pulumi": "*", 12 + "dotenv": "^16.4.5", 13 + "ts-node": "^10.9.2", 14 + "typescript": "^5.4.4" 15 + }, 16 + "devDependencies": { 17 + "@types/node": "^20.12.5" 18 + } 19 + }
portainer-agent-stack.yml jsonnet/portainer-agent-stack.yml
service.libsonnet jsonnet/service.libsonnet
+11
src/env.ts
··· 1 + import "dotenv/config"; 2 + 3 + export function getEnv(key: string) { 4 + const value = process.env[key]; 5 + 6 + if (!value) { 7 + throw new Error(`Missing environment variable: ${key}`); 8 + } 9 + 10 + return value; 11 + }
+308
src/index.ts
··· 1 + import * as pulumi from "@pulumi/pulumi"; 2 + import { ContainerService } from "./service"; 3 + import { getEnv } from "./env"; 4 + 5 + const dockerConfMount = (name: string) => ({ 6 + source: `/home/bas/docker/${name}`, 7 + target: "/config", 8 + type: "bind", 9 + }); 10 + 11 + const dataMount = { 12 + source: "/home/bas/data", 13 + target: "/home/bas/data", 14 + type: "bind", 15 + bindOptions: { 16 + propagation: "rshared", 17 + }, 18 + }; 19 + 20 + const gitMount = { 21 + target: "/home/bas/git", 22 + source: "/home/bas/git", 23 + type: "bind", 24 + }; 25 + 26 + const traefikService = await ContainerService.create("traefik", { 27 + image: "traefik", 28 + webPort: 8080, 29 + mounts: [ 30 + { 31 + source: "traefik", 32 + target: "/etc/traefik", 33 + type: "volume", 34 + }, 35 + { 36 + source: "/var/run/docker.sock", 37 + target: "/var/run/docker.sock", 38 + type: "bind", 39 + readOnly: true, 40 + }, 41 + ], 42 + ports: [80, 443], 43 + envs: [ 44 + `CF_API_EMAIL=${getEnv("EMAIL")}`, 45 + `CF_API_KEY=${getEnv("CF_API_KEY")}`, 46 + ], 47 + command: [ 48 + "--api", 49 + "--providers.docker.endpoint=unix:///var/run/docker.sock", 50 + "--providers.docker.exposedbydefault=false", 51 + "--entrypoints.http.address=:80", 52 + "--entrypoints.https.address=:443", 53 + "--entrypoints.https.http.tls=true", 54 + "--certificatesresolvers.cloudflare.acme.dnschallenge=true", 55 + "--certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare", 56 + "--certificatesresolvers.cloudflare.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53", 57 + "--certificatesresolvers.cloudflare.acme.storage=/etc/traefik/acme.json", 58 + `--certificatesresolvers.cloudflare.acme.email=${getEnv("EMAIL")}`, 59 + "--experimental.plugins.cloudflarewarp.modulename=github.com/BetterCorp/cloudflarewarp", 60 + "--experimental.plugins.cloudflarewarp.version=v1.3.3", 61 + ], 62 + 63 + labels: { 64 + "traefik.http.middlewares.httpsredirect.redirectscheme.scheme": "https", 65 + "traefik.http.middlewares.cloudflarewarp.plugin.cloudflarewarp.disableDefault": 66 + "false", 67 + "traefik.http.middlewares.auth.basicauth.users": 68 + "bas:$2y$05$XUkzwNnxl2sdNIMqrqspsulGw6fbj1smtwk7bMClLiDIsrR3EatOG", 69 + "traefik.http.routers.httpsredirect.entrypoints": "http", 70 + "traefik.http.routers.httpsredirect.middlewares": "httpsredirect", 71 + "traefik.http.routers.httpsredirect.rule": "HostRegexp(`{any:.+}`)", 72 + 73 + "traefik.http.routers.traefik.service": "api@internal", 74 + "traefik.http.routers.traefik.middlewares": "auth", 75 + }, 76 + }); 77 + 78 + const whoamiService = await ContainerService.create("whoami", { 79 + image: "traefik/whoami", 80 + webPort: 80, 81 + }); 82 + 83 + const wireguardService = await ContainerService.create("wireguard", { 84 + ports: [32400], 85 + mounts: [ 86 + dockerConfMount("wireguard"), 87 + { 88 + source: "/lib/modules", 89 + target: "/lib/modules", 90 + type: "bind", 91 + }, 92 + ], 93 + extraContainerOptions: { 94 + privileged: true, 95 + capabilities: { 96 + adds: ["NET_ADMIN", "SYS_MODULE"], 97 + }, 98 + sysctls: { "net.ipv4.conf.all.src_valid_mark": 1 }, 99 + }, 100 + }); 101 + 102 + const plexService = await ContainerService.create("plex", { 103 + webPort: 32400, 104 + envs: ["VERSION=latest"], 105 + mounts: [dockerConfMount("plex"), dataMount, gitMount], 106 + labels: { 107 + "traefik.http.routers.plex.tls.certresolver": "cloudflare", 108 + }, 109 + extraContainerOptions: { 110 + networkMode: pulumi.interpolate`container:${wireguardService.container.id}`, 111 + }, 112 + }); 113 + 114 + const overseerrService = await ContainerService.create("overseerr", { 115 + webPort: 5055, 116 + domain: "request", 117 + mounts: [dockerConfMount("overseerr"), dataMount], 118 + }); 119 + 120 + const sonarrService = await ContainerService.create("sonarr", { 121 + webPort: 8989, 122 + mounts: [dockerConfMount("sonarr"), dataMount], 123 + }); 124 + 125 + const radarrService = await ContainerService.create("radarr", { 126 + webPort: 7878, 127 + mounts: [dockerConfMount("radarr"), dataMount], 128 + }); 129 + 130 + const jackettService = await ContainerService.create("jackett", { 131 + webPort: 9117, 132 + ports: [9117], 133 + mounts: [dockerConfMount("jackett"), dataMount], 134 + }); 135 + 136 + const kitanaService = await ContainerService.create("kitana", { 137 + image: "pannal/kitana", 138 + webPort: 31337, 139 + mounts: [ 140 + { 141 + source: "kitana", 142 + target: "/app/data", 143 + type: "volume", 144 + }, 145 + ], 146 + }); 147 + 148 + const tautulliService = await ContainerService.create("tautulli", { 149 + webPort: 8181, 150 + mounts: [dockerConfMount("tautulli"), gitMount], 151 + envs: [ 152 + "DOCKER_MODS=linuxserver/mods:universal-package-install", 153 + "INSTALL_PIP_PACKAGES=-r /home/bas/git/PlexAniSync/requirements.txt", 154 + ], 155 + }); 156 + 157 + const qbittorrentService = await ContainerService.create("qbittorrent", { 158 + webPort: 8080, 159 + ports: [1337], 160 + envs: ["TORRENTING_PORT=1337"], 161 + mounts: [dockerConfMount("qbittorrent"), dataMount], 162 + }); 163 + 164 + const librespeedService = await ContainerService.create("librespeed", { 165 + image: "ghcr.io/librespeed/speedtest", 166 + webPort: 80, 167 + domain: "speedtest", 168 + envs: [ 169 + "TITLE=Speedtest | Bas", 170 + "TELEMETRY=true", 171 + "ENABLE_ID_OBFUSCATION=true", 172 + "REDACT_IP_ADDRESSES=true", 173 + `EMAIL=${getEnv("EMAIL")}`, 174 + `IPINFO_APIKEY=${getEnv("IPINFO_APIKEY")}`, 175 + ], 176 + }); 177 + 178 + ["get", "static", "files", "f", "i"].map(async (subdomain) => { 179 + await ContainerService.create(`caddy${subdomain}`, { 180 + image: "caddy", 181 + webPort: 80, 182 + domain: subdomain, 183 + command: [ 184 + "caddy", 185 + "file-server", 186 + "--browse", 187 + "--root=/var/www", 188 + `--domain=${subdomain}.bas.sh`, 189 + "--listen=:80", 190 + ], 191 + mounts: [ 192 + { 193 + source: "/home/bas/data/media/web/files", 194 + target: "/var/www", 195 + type: "bind", 196 + bindOptions: { 197 + propagation: "rshared", 198 + }, 199 + }, 200 + ], 201 + }); 202 + }); 203 + 204 + const autolanguagesService = await ContainerService.create("autolanguages", { 205 + image: "remirigal/plex-auto-languages", 206 + envs: [ 207 + `PLEX_TOKEN=${getEnv("PLEX_TOKEN")}`, 208 + "PLEX_URL=https://plex.bas.sh:443", 209 + "UPDATE_LEVEL=season", 210 + "TRIGGER_ON_ACTIVITY=true", 211 + "REFRESH_ON_SCAN=true", 212 + "NOTIFICATIONS_ENABLE=true", 213 + `NOTIFICATIONS_APPRISE_CONFIGS=[{ urls: ["${getEnv("DISCORD_AUTO_LANGUAGES_WEBHOOK")}"], events: ["play_or_activity", "scheduler"] }]`, 214 + ], 215 + }); 216 + 217 + const theloungeService = await ContainerService.create("thelounge", { 218 + webPort: 9000, 219 + domain: "irc", 220 + mounts: [dockerConfMount("thelounge")], 221 + }); 222 + 223 + const autobrrService = await ContainerService.create("autobrr", { 224 + image: "ghcr.io/autobrr/autobrr", 225 + webPort: 7474, 226 + mounts: [dockerConfMount("autobrr")], 227 + }); 228 + 229 + const ankiService = await ContainerService.create("anki", { 230 + image: "ankicommunity/anki-sync-server:latest-develop", 231 + webPort: 27701, 232 + domain: "anki", 233 + envs: [ 234 + "ANKISYNCD_AUTH_DB_PATH=/app/data/auth.db", 235 + "ANKISYNCD_DATA_ROOT=/app/data/collections", 236 + "ANKISYNCD_SESSION_DB_PATH=/app/data/session.db", 237 + ], 238 + mounts: [ 239 + { 240 + source: "/home/bas/docker/anki", 241 + target: "/app/data", 242 + type: "bind", 243 + }, 244 + ], 245 + }); 246 + 247 + const sabnzbdService = await ContainerService.create("sabnzbd", { 248 + webPort: 8080, 249 + mounts: [dockerConfMount("sabnzbd"), dataMount], 250 + }); 251 + 252 + const recyclarrService = await ContainerService.create("recyclarr", { 253 + image: "recyclarr/recyclarr", 254 + mounts: [dockerConfMount("recyclarr")], 255 + }); 256 + 257 + const qbittoolsService = await ContainerService.create("qbittools", { 258 + image: "registry.gitlab.com/alexkm/qbittools", 259 + mounts: [ 260 + { 261 + source: "/home/bas/docker/qbittorrent", 262 + target: "/qbittorrent", 263 + type: "bind", 264 + }, 265 + dataMount, 266 + ], 267 + command: [ 268 + "reannounce", 269 + "-C", 270 + "/qbittorrent/qBittorrent.conf", 271 + "-s", 272 + "https://qbittorrent.bas.sh:443", 273 + "-U", 274 + getEnv("QBITTORRENT_USERNAME"), 275 + "-P", 276 + getEnv("QBITTORRENT_PASSWORD"), 277 + ], 278 + }); 279 + 280 + const resilioSyncService = await ContainerService.create("resilio-sync", { 281 + domain: "sync", 282 + webPort: 8888, 283 + ports: [55555], 284 + mounts: [ 285 + dockerConfMount("resilio-sync"), 286 + { 287 + source: "/home/bas/data/media/sync", 288 + target: "/sync", 289 + type: "bind", 290 + bindOptions: { 291 + propagation: "rshared", 292 + }, 293 + }, 294 + ], 295 + }); 296 + 297 + const mkvtoolnixService = await ContainerService.create("mkvtoolnix", { 298 + image: "jlesage/mkv-muxing-batch-gui", 299 + webPort: 5800, 300 + mounts: [dockerConfMount("mkvtoolnix"), dataMount], 301 + envs: [ 302 + `VNC_PASSWORD=${getEnv("VNC_PASSWORD")}`, 303 + "DARK_MODE=true", 304 + "APP_NICENESS=10", 305 + "KEEP_APP_RUNNING=1", 306 + "ENABLE_CJK_FONT=1", 307 + ], 308 + });
+218
src/service.ts
··· 1 + import * as pulumi from "@pulumi/pulumi"; 2 + import * as docker from "@pulumi/docker"; 3 + import { getEnv } from "./env"; 4 + 5 + const convertLabels = (labels: Args["labels"]) => 6 + labels && 7 + Object.entries(labels).map(([label, value]) => ({ 8 + label, 9 + value, 10 + })); 11 + 12 + const convertPorts = (ports?: Args["ports"]) => { 13 + return ports?.map((port) => { 14 + if (typeof port === "number") { 15 + return { 16 + internal: port, 17 + external: port, 18 + }; 19 + } 20 + 21 + const [internal, external] = port.split(":"); 22 + return { 23 + internal: parseInt(internal, 10), 24 + external: parseInt(external, 10), 25 + }; 26 + }); 27 + }; 28 + 29 + type Registry = "ghcr.io" | "registry.gitlab.com"; 30 + 31 + const getToken = async (registry: Registry, imageName: string) => { 32 + let url; 33 + 34 + if (registry === "ghcr.io") { 35 + url = `https://ghcr.io/token?scope=repository:${imageName}:pull`; 36 + } else { 37 + url = `https://gitlab.com/jwt/auth?service=container_registry&scope=repository:${imageName}:pull`; 38 + } 39 + 40 + const response = await fetch(url); 41 + 42 + if (!response.ok) { 43 + throw new Error( 44 + `Failed to fetch token for ${imageName}, status: ${response.status}, body: ${await response.text()}`, 45 + ); 46 + } 47 + 48 + return response.json().then(({ token }) => token); 49 + }; 50 + 51 + const getLatestImageName = async (image: string) => { 52 + const match = image.match( 53 + /^(?:(?<registry>ghcr\.io|registry\.gitlab\.com)\/)?(?<name>(?:(?!:).)+)(?::(?<tag>.+))?/, 54 + ); 55 + 56 + if (!match?.groups) { 57 + throw new Error(`Failed to parse image: ${image}`); 58 + } 59 + 60 + const registry = match.groups.registry as Registry; 61 + const tag = match.groups.tag || "latest"; 62 + const name = match.groups.name; 63 + 64 + let url; 65 + let headers; 66 + 67 + if (registry) { 68 + const token = await getToken(registry, name); 69 + 70 + headers = { 71 + Authorization: `Bearer ${token}`, 72 + Accept: "application/vnd.oci.image.index.v1+json", 73 + }; 74 + url = `https://${registry}/v2/${name}/manifests/${tag}`; 75 + } else if (name.includes("/")) { 76 + url = `https://hub.docker.com/v2/repositories/${name}/tags/${tag}`; 77 + } else { 78 + url = `https://hub.docker.com/v2/repositories/library/${name}/tags/${tag}`; 79 + } 80 + 81 + const response = await fetch(url, { headers }); 82 + 83 + if (!response.ok) { 84 + throw new Error( 85 + `Failed to fetch tags for ${image}, status: ${response.status}, body: ${await response.text()}`, 86 + ); 87 + } 88 + 89 + const tags = await response.json(); 90 + const digest = (tags?.manifests ?? tags?.images)?.find( 91 + (image: any) => 92 + image.architecture === "amd64" || 93 + image.platform?.architecture === "amd64", 94 + )?.digest; 95 + 96 + if (!digest) { 97 + throw new Error( 98 + `Failed to find latest digest for ${image}, tags: ${JSON.stringify(tags, null, 2)}`, 99 + ); 100 + } 101 + 102 + if (registry) { 103 + return `${registry}/${name}@${digest}`; 104 + } else { 105 + return `${name}@${digest}`; 106 + } 107 + }; 108 + 109 + type AtLeast<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>; 110 + 111 + type Args = AtLeast< 112 + { 113 + image: string; 114 + domain: string; 115 + hostRule: string; 116 + webPort: number; 117 + command: string[]; 118 + middlewares: string[]; 119 + labels: Record<string, string>; 120 + envs: string[]; 121 + mounts: docker.types.input.ContainerMount[]; 122 + ports: (`${number}:${number}` | number)[]; 123 + extraContainerOptions: Partial<docker.ContainerArgs>; 124 + }, 125 + "image" 126 + >; 127 + 128 + export class ContainerService extends pulumi.ComponentResource { 129 + public container: docker.Container; 130 + 131 + public image: docker.RemoteImage; 132 + 133 + static async create( 134 + name: string, 135 + args: Partial<Args>, 136 + opts?: pulumi.ComponentResourceOptions, 137 + ) { 138 + const fullArgs: Args = { 139 + ...args, 140 + image: await getLatestImageName(args.image || `linuxserver/${name}`), 141 + }; 142 + 143 + return new ContainerService(name, fullArgs, opts); 144 + } 145 + 146 + constructor( 147 + name: string, 148 + args: Args, 149 + opts?: pulumi.ComponentResourceOptions, 150 + ) { 151 + super("bas:docker:ContainerService", name, args, opts); 152 + 153 + this.image = new docker.RemoteImage( 154 + `${name}`, 155 + { 156 + name: args.image, 157 + keepLocally: true, 158 + }, 159 + { 160 + parent: this, 161 + }, 162 + ); 163 + 164 + let labels = {}; 165 + 166 + if (args.webPort) { 167 + labels = { 168 + "traefik.enable": "true", 169 + [`traefik.http.services.${name}.loadbalancer.server.port`]: 170 + args.webPort.toString(), 171 + [`traefik.http.routers.${name}.rule`]: `Host(\`${args.hostRule || `${args.domain || name}.bas.sh`}\`)`, 172 + [`traefik.http.routers.${name}.entrypoints`]: "https", 173 + [`traefik.http.routers.${name}.tls`]: "true", 174 + [`traefik.http.routers.${name}.middlewares`]: [ 175 + "cloudflarewarp", 176 + ...(args.middlewares || []), 177 + ].join(","), 178 + }; 179 + } 180 + 181 + labels = { 182 + ...labels, 183 + ...args.labels, 184 + }; 185 + 186 + const envs = [ 187 + `PUID=${getEnv("PUID")}`, 188 + `PGID=${getEnv("PGID")}`, 189 + `TZ=${getEnv("TZ")}`, 190 + ...(args.envs || []), 191 + ]; 192 + 193 + this.container = new docker.Container( 194 + name, 195 + { 196 + image: this.image.imageId, 197 + name, 198 + command: args.command, 199 + restart: "always", 200 + labels: convertLabels(labels), 201 + envs, 202 + ports: convertPorts(args.ports), 203 + mounts: args.mounts, 204 + ...args.extraContainerOptions, 205 + }, 206 + { 207 + parent: this, 208 + deleteBeforeReplace: true, 209 + replaceOnChanges: ["mounts"], 210 + }, 211 + ); 212 + 213 + this.registerOutputs({ 214 + container: this.container, 215 + image: this.image, 216 + }); 217 + } 218 + }
+18
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "strict": true, 4 + "outDir": "bin", 5 + "target": "esnext", 6 + "module": "esnext", 7 + "moduleResolution": "node", 8 + "sourceMap": true, 9 + "experimentalDecorators": true, 10 + "pretty": true, 11 + "noFallthroughCasesInSwitch": true, 12 + "noImplicitReturns": true, 13 + "forceConsistentCasingInFileNames": true 14 + }, 15 + "files": [ 16 + "index.ts" 17 + ] 18 + }