I run my public websites, private utilities, and long-lived AI agents on one small cloud platform. Google Cloud provides the foundation, Kubernetes gives each workload a common deployment API, Cloudflare handles traffic and access, Pulumi defines the infrastructure, and GitHub Actions delivers changes. andymeier.dev, Benji, and Minnie are the current workloads.
I operate it with the same discipline I bring to client infrastructure: changes are defined in code, previewed, and reviewed; identities are narrowly scoped; secrets stay out of repositories; and deployments are observable and reproducible. Because I own the cost and blast radius, it also serves as a proving ground for new tools and architectural patterns before I apply what I learn to client work.
I favor a small, coherent set of tools with strong APIs. In an agent-driven workflow, that lets me and my agents build, inspect, and diagnose the same systems programmatically. Every client environment has its own scale, risk, compliance, and operational requirements, so experience here informs those decisions rather than becoming a default blueprint.
Architecture at a glance
System context
Cloudflare fronts every public and protected application, while GitHub Actions and Pulumi deliver reviewed changes to Google Cloud. Google Workspace provides my identity for protected applications.
Runtime
andymeier.dev serves the public website, while Benji and Minnie run as long-lived AI agents. Cloudflare connectors route accepted requests to those workloads, Seq, or Snowplow. Applications send telemetry to Seq; managed storage and analytics stay outside the request path.
Deployment
Application images live in Artifact Registry. andymeier.dev, Benji, Minnie, Cloudflare connectors, Seq, and Snowplow run in one zonal GKE cluster. Secret Manager, Cloud Storage, Pub/Sub, BigQuery, Cloud Logging, and Cloud Monitoring remain managed Google Cloud services.
How it is organized
I organize the infrastructure in dependency order. Identity comes first, followed by shared infrastructure, application environments, and application-owned deployments.
personal-cloud/
├── identity/
│ ├── project.ts
│ ├── serviceAccounts.ts
│ └── oidc.ts
├── infrastructure/
│ ├── gcp/
│ ├── cloudflare/
│ └── kubernetes/
├── environments/
│ ├── shared.yaml
│ └── <application>.yaml
└── applications/
├── andymeier/
└── <personal-application>/Each resource has one owner. The infrastructure layer owns cluster-wide capabilities; application repositories own their workloads, routes, and access policies. Environments connect the two without copying identifiers or credentials between repositories.
Namespaces are the runtime boundary. Each application gets its own namespace and deployment identity, and Kubernetes RBAC limits the deployer to that namespace. Workload Identity gives a Pod a narrowly scoped Google identity only when it needs a managed service.
Personal applications and agents
andymeier.dev is a stateless web application behind a private Service. Benji and Minnie are my two long-running AI agents. I use them for coding, scheduled routines, email and messaging, task follow-up, and other recurring work. All three applications share deployment, identity, networking, and observability conventions.
Benji and Minnie share one runtime but deploy as separate single-replica StatefulSets. Each has independent configuration, identity, persistent storage, hostname, Cloudflare policy, and Pi coding agent sessions. Their runtime handles webhooks, scheduled work, and longer task sessions. Each agent can evolve or restart without sharing credentials or session state.
The agents need persistent processes, private endpoints, background work, workload identity, durable volumes, and traceable task execution. Kubernetes gives them and the stateless website one operational model without forcing them into the same architecture.
Why Google Cloud
Google Cloud fits this environment because Google Workspace, IAM, GKE, Artifact Registry, Secret Manager, Cloud Logging, and Cloud Monitoring share a coherent identity and operations model. Its APIs and command-line tools are consistent, and Google Kubernetes Engine (GKE) is the managed Kubernetes service I know best and prefer.
The economics of a small zonal cluster are unusually good. Google charges a cluster management fee, but the GKE free tier provides $74.40 in monthly credits per billing account, offsetting the management fee for one zonal Standard or Autopilot cluster. Worker nodes, disks, networking, and usage-based services remain billable, but the managed control plane adds no incremental fee within that allowance.
I use a zonal Standard cluster to control node pools, pack small workloads efficiently, and avoid paying for multi-zone availability I do not need. Auto-repair, auto-upgrade, a regular release channel, and reproducible configuration handle much of the maintenance.
This TypeScript is an abridged version of my zonal GKE configuration:
const cluster = new gcp.container.Cluster('personal', {
location: `${region}-b`,
network: network.id,
subnetwork: subnet.id,
removeDefaultNodePool: true,
ipAllocationPolicy: {
clusterSecondaryRangeName: 'pods',
servicesSecondaryRangeName: 'services',
},
privateClusterConfig: {
enablePrivateNodes: true,
enablePrivateEndpoint: false,
},
releaseChannel: { channel: 'REGULAR' },
workloadIdentityConfig: {
workloadPool: `${projectId}.svc.id.goog`,
},
})
new gcp.container.NodePool('personal-primary', {
cluster: cluster.name,
autoscaling: { minNodeCount: 1, maxNodeCount: 4 },
management: { autoRepair: true, autoUpgrade: true },
})Kubernetes without platform engineering
I deliberately use a small Kubernetes vocabulary: Namespaces, Deployments, and Services, with Jobs and ConfigMaps only when a workload requires them. Deployments create and replace Pods, Services give them a stable private address, and Namespaces provide ownership and authorization boundaries.
That small subset still provides a useful programmatic API. A Deployment declares its image, replicas, probes, resources, and security context. Kubernetes reconciles the workload, restarts failed containers, and waits for readiness during a rollout. That gives me repeatable deployments without custom process managers or remote-shell scripts.
The same tools list workloads, inspect events, stream logs, restart rollouts, and forward private ports for every application. GKE sends logs to Cloud Logging and system metrics to Cloud Monitoring by default. Keeping the Kubernetes vocabulary small keeps its complexity bounded.
This is an abridged application Deployment and Service:
const deployment = new k8s.apps.v1.Deployment('app', {
metadata: { namespace: config.k8s.namespace },
spec: {
replicas: 1,
selector: { matchLabels: labels },
template: {
metadata: { labels },
spec: {
securityContext: { runAsNonRoot: true },
containers: [{
name: 'app',
image: image.imageRef,
resources: {
requests: { cpu: '25m', memory: '64Mi' },
limits: { cpu: '250m', memory: '256Mi' },
},
livenessProbe: {
httpGet: { path: '/health', port: 5000 },
},
readinessProbe: {
httpGet: { path: '/health', port: 5000 },
},
}],
},
},
},
})
new k8s.core.v1.Service('app', {
metadata: { namespace: config.k8s.namespace },
spec: { type: 'ClusterIP', selector: labels, ports: [{ port: 80 }] },
})Cloudflare for networking and access
All application origins stay on the private network. A Cloudflare Tunnel connector runs in Kubernetes and creates an outbound-only connection to Cloudflare. The cluster does not need a public application load balancer, and its Services can remain private ClusterIP addresses. Public traffic reaches an accepted hostname at Cloudflare and is forwarded through the tunnel to the appropriate Service.
For a private application, Cloudflare Access applies an identity policy before the request ever reaches the origin. I can define a policy for each hostname and use Google Workspace as the identity provider. A public website can remain public, while an administration page or experimental utility can require my account or an approved group. Both use the same tunnel and private network. Cloudflare exposes APIs for DNS, tunnel routes, and Access policies, so I manage those controls through Pulumi rather than configuring them only in the dashboard.
For browser-based applications, this gives me per-application access without exposing origins or granting a device network-wide access.
Observability with Seq
I use Seq for application observability because it is equally easy to run locally in Docker or deploy as a StatefulSet in Kubernetes. Applications export structured logs and traces through OpenTelemetry, so the instrumentation does not depend on a proprietary logging client. The same configuration sends local development telemetry to a local Seq instance and production telemetry to the private service in the cluster.
Seq puts correlated logs and traces in one event stream. I can move from an error to its surrounding events and trace, then use the query language in the UI or the HTTP API from an agent. Benji, Minnie, and coding agents can retrieve recent errors, filter telemetry, and follow traces without reproducing a browser workflow. Cloudflare Access protects the interface.
Benji and Minnie each have a separate Google Workspace account and Kubernetes identity. Their Kubernetes identities can inspect workloads, events, and logs without changing them. Because a Workspace account is also a Google Cloud IAM principal, I can grant narrowly scoped viewer roles for relevant Google Cloud APIs when an agent needs cloud-level context. Seq covers application behavior and correlated traces; Cloud Logging and Cloud Monitoring provide Kubernetes events, system logs, infrastructure metrics, and cluster context. Together, those APIs let an agent gather evidence without receiving deployment permissions.
Pulumi and environments
I define infrastructure with Pulumi and TypeScript to reuse policies and resource shapes with normal language and refactoring tools. Provider types and IntelliSense expose available properties while I write and review changes, and strong types provide useful evidence even when AI helps with discovery.
I keep resource modules small, ownership explicit, and abstractions limited. Pulumi calculates previews, records state, and applies only the reviewed difference.
Pulumi ESC is the environment boundary. An environment composes stack outputs, non-secret configuration, short-lived cloud credentials, and selected secrets into the exact values a deployment needs. The GCP login provider exchanges OpenID Connect identity for a temporary Google Cloud token. Long-lived secrets stay in GCP Secret Manager, where I can rotate them independently and audit access.
For secret access, I create the secret container in GCP, grant a dedicated service account permission to read only the required secret, and allow a specific Pulumi environment to impersonate that account through OIDC. ESC then reads the current secret value when the environment opens. The application repository does not receive a service-account key, and one environment cannot automatically read another environment's secrets. An abridged ESC environment captures that trust chain:
values:
gcpLogin:
fn::open::gcp-login:
project: <project-number>
oidc:
workloadPoolId: <workload-pool>
providerId: pulumi
serviceAccount: <environment-service-account>
subjectAttributes:
- currentEnvironment.name
secrets:
fn::open::gcp-secrets:
login: ${gcpLogin}
access:
applicationApiKey:
name: <secret-name>
environmentVariables:
GOOGLE_OAUTH_ACCESS_TOKEN: ${gcpLogin.accessToken}
pulumiConfig:
application:apiKey: ${secrets.applicationApiKey}GitHub for delivery
Each application has a GitHub repository. Pull requests collect code and infrastructure changes, run tests, and produce a Pulumi preview. Merging publishes an immutable image, applies the reviewed update, waits for Kubernetes readiness, and runs browser or API checks.
GitHub Actions requests an OIDC token, and Pulumi exchanges it for short-lived, scoped access. ESC obtains a separate temporary Google Cloud identity, so trust follows the repository and environment rather than stored access tokens or cloud keys.
The core deployment workflow is small:
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- name: Authenticate with Pulumi
uses: pulumi/auth-actions@v2
with:
organization: <organization>
requested-token-type: <scoped-token-type>
- name: Preview or update
uses: pulumi/actions@v7
with:
work-dir: ./pulumi
stack-name: prod
command: <preview-or-up>Locally, the GitHub CLI makes the same workflow practical from the terminal. I use it to create and inspect pull requests, read check results, review diffs, and manage branches. The GitHub API also gives agents the same repository, pull-request, and check data without requiring a browser. The pull request—not a workstation running an unreviewed production update—is the normal unit of change.
Intentional tradeoffs
A zonal GKE cluster is not the smallest possible way to host one website. A managed static host or one virtual machine would use fewer concepts. The calculation changes when several personal applications reuse the same cluster, private ingress, deployment workflow, observability, and identity model. Adding another application becomes a namespace, a Deployment, a Service, a route, and a workflow rather than another hand-configured server.
The zonal design also accepts that a zone-level incident can interrupt every application. Regional control planes and multi-zone node pools would improve availability but increase the baseline compute cost and operational surface. For personal applications, I prefer health probes, reproducible deployments, immutable images, and managed data services over paying continuously for regional redundancy.
Cloudflare, Google Cloud, Pulumi, Kubernetes, and GitHub are deliberate dependencies. Replacing any one of them would require real work. I accept that coupling because each product removes more operational burden than it adds, and because the boundaries between them are still visible: containers, Kubernetes resources, DNS routes, OIDC identities, and TypeScript programs.
The result is a small, coherent platform that keeps personal applications inexpensive, private by default, observable, and programmatically deployable. A narrow Kubernetes vocabulary and short-lived identities keep it manageable for one operator.