| name | torvalds-deployment |
| description | Use when asking about adding services, createXxxDeployment patterns, or homelab deployment conventions. Services use per-namespace charts. |
Homelab Service Deployment
Overview
Services are deployed across multiple namespaces using an app-of-apps pattern. Each service
category has its own namespace and Helm chart (e.g., media, home, postal). Services follow
a consistent createXxxDeployment() pattern.
First-Party ghcr Images Must Be Public
The cluster (torvalds) has no imagePullSecret / dockerconfigjson infrastructure anywhere in
src/cdk8s. Every first-party image (ghcr.io/shepherdjerred/*) must therefore be a public ghcr
package; a private package makes kubelet pull anonymously, ghcr returns 401 Unauthorized →
ImagePullBackOff.
Newly-created ghcr packages default to private, so a brand-new service breaks on first deploy
until its package is flipped to public — even when versions.ts has the correct digest (digests are
updated manually in versions.ts since the CI commit-back was removed 2026-07; visibility is separate).
Check visibility without cluster access:
tok=$(curl -s "https://ghcr.io/token?scope=repository:shepherdjerred/<pkg>:pull&service=ghcr.io" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))')
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $tok" "https://ghcr.io/v2/shepherdjerred/<pkg>/tags/list"
Changing visibility is GitHub-UI only (no REST API; the default gh token lacks write:packages):
the owner must set it at
https://github.com/users/shepherdjerred/packages/container/<pkg>/settings → Danger Zone → Change
visibility → Public. Ask the user — you cannot do this.
Namespace Structure
| Namespace | Services | Chart File |
|---|
media | Plex, Radarr, Sonarr, Bazarr, Prowlarr, etc. | media.ts |
home | Home Assistant, HA automations | home.ts |
postal | Postal mail server, MariaDB | postal.ts |
syncthing | Syncthing | syncthing.ts |
golink | GoLink | golink.ts |
freshrss | FreshRSS | freshrss.ts |
pokemon | Pokemon bots | pokemon.ts |
gickup | Gickup | gickup.ts |
Standard Deployment Pattern
Step 1: Create Deployment File
Create src/cdk8s/src/resources/{category}/yourservice.ts:
import { Chart, Size } from "cdk8s";
import {
Cpu,
Deployment,
DeploymentStrategy,
type PersistentVolumeClaim,
Service,
Volume,
} from "cdk8s-plus-31";
import {
LINUXSERVER_GID,
withCommonLinuxServerProps,
} from "../../misc/linux-server.ts";
import { ZfsNvmeVolume } from "../../misc/zfs-nvme-volume.ts";
import { TailscaleIngress } from "../../misc/tailscale.ts";
import versions from "../../versions.ts";
export function createYourServiceDeployment(
chart: Chart,
claims?: {
downloads?: PersistentVolumeClaim;
media?: PersistentVolumeClaim;
},
) {
const deployment = new Deployment(chart, "yourservice", {
replicas: 1,
strategy: DeploymentStrategy.recreate(),
securityContext: {
fsGroup: LINUXSERVER_GID,
},
});
const configVolume = new ZfsNvmeVolume(chart, "yourservice-pvc", {
storage: Size.gibibytes(8),
});
deployment.addContainer(
withCommonLinuxServerProps({
image: `ghcr.io/linuxserver/yourservice:${versions["linuxserver/yourservice"]}`,
portNumber: 8080,
volumeMounts: [
{
path: "/config",
volume: Volume.fromPersistentVolumeClaim(
chart,
"yourservice-config-volume",
configVolume.claim,
),
},
...(claims?.downloads
? [
{
path: "/downloads",
volume: Volume.fromPersistentVolumeClaim(
chart,
"yourservice-downloads-volume",
claims.downloads,
),
},
]
: []),
],
resources: {
cpu: {
request: Cpu.millis(100),
limit: Cpu.millis(1000),
},
memory: {
request: Size.mebibytes(256),
limit: Size.mebibytes(512),
},
},
}),
);
const service = new Service(chart, "yourservice-service", {
selector: deployment,
ports: [{ port: 8080 }],
});
new TailscaleIngress(chart, "yourservice-ingress", {
service,
host: "yourservice",
});
}
Step 2: Add Version
Edit src/cdk8s/src/versions.ts:
const versions = {
"linuxserver/yourservice": "1.0.0@sha256:abc123...",
};
Step 3: Register in Appropriate Chart
For media services, edit src/cdk8s/src/cdk8s-charts/media.ts:
import { createYourServiceDeployment } from "../resources/media/yourservice.ts";
export function createMediaChart(app: App) {
const chart = new Chart(app, "media", {
namespace: "media",
disableResourceNameHashes: true,
});
const downloadsVolume = new ZfsSataVolume(chart, "downloads-hdd-pvc", {
storage: Size.tebibytes(1),
});
createYourServiceDeployment(chart, {
downloads: downloadsVolume.claim,
});
}
Step 4: Create ArgoCD Application (if new namespace)
If creating a new namespace, add ArgoCD app in src/cdk8s/src/resources/argo-applications/yournamespace.ts:
import { Chart } from "cdk8s";
import { Application } from "../../generated/imports/argoproj.io.ts";
import { createArgoApplication } from "./common.ts";
export function createYourNamespaceApplication(chart: Chart) {
return createArgoApplication(chart, "yournamespace", {
chart: {
repoUrl: "https://chartmuseum.tailnet-1a49.ts.net",
chartName: "yournamespace",
},
destination: {
namespace: "yournamespace",
},
});
}
Then register in src/cdk8s/src/cdk8s-charts/apps.ts. (Charts were also listed in HELM_CHARTS in .dagger/src/helm.ts for the automated helm package/push, but that pipeline was removed 2026-07 — chart packaging/publishing to ChartMuseum is manual now.)
Advanced Patterns
Multiple Containers (Sidecar Pattern)
deployment.addContainer(
withCommonLinuxServerProps({
image: `...`,
portNumber: 8080,
}),
);
deployment.addContainer(
withCommonProps({
name: "exporter",
image: `...`,
ports: [{ number: 9090, name: "metrics" }],
securityContext: {
ensureNonRoot: true,
readOnlyRootFilesystem: true,
user: 65534,
group: 65534,
},
}),
);
Prometheus ServiceMonitor
import { ServiceMonitor } from "../../generated/imports/monitoring.coreos.com.ts";
new ServiceMonitor(chart, "yourservice-monitor", {
metadata: {
name: "yourservice-monitor",
labels: { release: "prometheus" },
},
spec: {
endpoints: [{ port: "metrics", interval: "60s", path: "/metrics" }],
selector: { matchLabels: { app: "yourservice" } },
},
});
1Password Secrets
import { OnePasswordItem } from "../../generated/imports/onepassword.com.ts";
const secrets = new OnePasswordItem(chart, "yourservice-secrets", {
spec: {
itemPath: "vaults/xxx/items/yyy",
},
});
envVariables: {
API_KEY: EnvValue.fromSecretValue({
secret: Secret.fromSecretName(chart, "api-key", secrets.name),
key: "password",
}),
},
Public Access via Funnel
new TailscaleIngress(chart, "yourservice-ingress", {
service,
host: "yourservice",
funnel: true,
});
Directory Structure
src/cdk8s/src/
├── cdk8s-charts/
│ ├── media.ts # Media namespace chart
│ ├── home.ts # Home namespace chart
│ ├── postal.ts # Postal namespace chart
│ └── ... # Other namespace charts
├── resources/
│ ├── torrents/ # Sonarr, Radarr, qBittorrent
│ ├── media/ # Plex, Tautulli, etc.
│ ├── home/ # Home Assistant
│ ├── mail/ # Postal mail server
│ └── argo-applications/ # ArgoCD app definitions
└── helm/
├── media/ # Media Helm chart
├── home/ # Home Helm chart
└── ... # Other Helm charts
Key Files
src/cdk8s/src/cdk8s-charts/media.ts - Media namespace chart
src/cdk8s/src/cdk8s-charts/home.ts - Home namespace chart
src/cdk8s/src/resources/torrents/sonarr.ts - Reference example
src/cdk8s/src/resources/media/plex.ts - Complex example with sidecars