I run my personal website, company website, and long-lived AI agents on a shared cloud platform. andymeier.dev publishes my articles, meiermade.com presents my consulting business, and Benji and Minnie help with recurring work. 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.
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 and meiermade.com are public websites; Benji and Minnie are long-lived AI agents with protected interfaces and authenticated webhook routes. A shared OpenTelemetry Collector separates internal application telemetry from constrained public browser telemetry. ClickStack combines ClickHouse storage with HyperDX for exploration. The agents use managed PostgreSQL for durable inboxes and task-control state.
Deployment
Application images live in Artifact Registry. The websites, agents, Collector, HyperDX, ClickHouse, and MongoDB run in one zonal GKE cluster. Each website and agent Pod includes its own cloudflared sidecar; shared connectors expose platform endpoints. Cloud SQL PostgreSQL, Secret Manager, Cloud Storage, Cloud Logging, and Cloud Monitoring sit outside the cluster as managed Google Cloud services.
How it is organized
These are separate repositories in the Meier Made GitHub organization, not directories in a single application. I organize the infrastructure in dependency order: workload identity, shared infrastructure, application environments, and application-owned deployments.
platform-identity/ # Cloud workload identities and OIDC trust
platform-infrastructure/ # Shared compute, networking, data, and observability
environments/ # Pulumi ESC configuration
andymeier/ # Personal website and articles
meiermade/ # Company website
agent/ # Shared runtime for Benji and Minnie
skills/ # Reusable agent instructions and toolsEach resource has one owner. The platform-infrastructure repository owns shared capabilities. The andymeier, meiermade, and agent repositories each contain app/ for application code and pulumi/ for their deployments, routes, and access policies. The environments repository composes stack outputs and credentials through ESC. The skills repository supplies reusable instructions and command tools without owning the services they access.
Namespaces and Kubernetes RBAC define workload ownership and deployment permissions. They are not automatic network isolation: the cluster enables NetworkPolicy enforcement, but applications must declare the traffic restrictions they need, and there is no cluster-wide default-deny policy. Workload Identity binds Kubernetes service accounts to authorized Google service accounts without distributing cloud keys.
Personal applications and agents
andymeier.dev and meiermade.com are stateless F# web applications. The first is my personal website and technical blog; the second is my company website, with services, projects, team information, and public policies. Both run behind private origins and share deployment, networking, and observability conventions.
Benji and Minnie are my two long-running AI agents. I use them for coding, scheduled routines, email and messaging, and task follow-up. They share one runtime but deploy as separate single-replica StatefulSets, with separate configuration, Kubernetes service accounts, persistent workspaces, hostnames, Cloudflare policies, and Pi coding agent sessions. Managed Cloud SQL PostgreSQL holds each agent's durable communication inbox and task-control state, while persistent volumes hold workspaces and session files.
The agents need persistent processes, protected endpoints, background work, workload identity, and durable state. Kubernetes gives them and the stateless websites 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 excellent. 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 start with Namespaces, Deployments, and Services, then add StatefulSets and persistent volumes for agents, Jobs for bounded work, and operators for the databases inside the cluster. Deployments create and replace Pods, Services give them stable private addresses, and Namespaces organize ownership. The websites stay simple even when another workload needs more machinery.
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. I configure GKE to send system-component logs to Cloud Logging and infrastructure metrics to Cloud Monitoring. Routine application stdout and stderr are not also collected into Cloud Logging; application observability uses OpenTelemetry and ClickStack.
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. For each website and agent, cloudflared runs as a sidecar in the application Pod and forwards to the app over localhost; Services remain private ClusterIP addresses. Shared platform connectors route to internal Services for endpoints such as HyperDX and the browser Collector.
For a private application, Cloudflare Access checks identity before forwarding the request to the origin. Access applications protect hostnames and attach reusable policies by ID, while Google Workspace supplies the human identity. The tunnel carries traffic; the Access application determines who may use it. I define both through Pulumi.
HyperDX is a concrete example. These abridged TypeScript examples adapted from platform-infrastructure show the shared policy, protected application, and tunnel route. Provider setup and imports are omitted; configuration and referenced resource outputs come from the surrounding modules.
Define a shared policy
I define the administrator policy once, then reuse it across protected applications:
const accountId = config.cloudflareConfig.accountId
const allowAdmins = new cloudflare.ZeroTrustAccessPolicy('allow-admins', {
accountId,
name: 'Allow Admins',
decision: 'allow',
includes: [{ email: { email: '[email protected]' } }],
}, { provider })Protect the HyperDX hostname
The HyperDX Access application selects the shared Google Workspace identity provider and attaches the administrator policy by ID:
const hostname = 'hyperdx.example.com'
const hyperdx = new cloudflare.ZeroTrustAccessApplication('hyperdx.example.com', {
accountId,
name: 'HyperDX',
domain: hostname,
type: 'self_hosted',
allowedIdps: [accessIdentityProvider.googleAccessIdentityProviderId],
autoRedirectToIdentity: true,
httpOnlyCookieAttribute: true,
policies: [{ id: allowAdmins.id, precedence: 1 }],
}, { provider })The full application also attaches the reusable Pi, Benji, and Minnie service-token policies. Benji and Minnie additionally require the platform NAT address. The platform-infrastructure repository exports shared policy and identity-provider IDs; product repositories consume them through ESC rather than redefining the shared resources.
Route the tunnel to the private Service
The shared platform connector runs as a standalone Kubernetes Deployment, unlike the application sidecars. This excerpt keeps only its HyperDX route. The connector requires an Access token for the application's audience before forwarding to the internal Service:
const platformTunnel = new cloudflare.ZeroTrustTunnelCloudflared('platform', {
accountId,
name: 'platform',
configSrc: 'cloudflare',
}, { provider })
new cloudflare.ZeroTrustTunnelCloudflaredConfig('gcp-platform', {
accountId,
tunnelId: platformTunnel.id,
source: 'cloudflare',
config: {
ingresses: [{
hostname,
// Internal Kubernetes Service, not the connector's localhost.
service: hyperdxDeployment.uiServiceUrl,
originRequest: {
access: {
required: true,
audTags: [hyperdx.aud],
teamName: config.cloudflareConfig.teamName,
},
},
}, {
service: 'http_status:404',
}],
},
}, { provider })A proxied CNAME points the hostname to <tunnel-id>.cfargotunnel.com. Pulumi retrieves the tunnel token and places it in a Kubernetes Secret consumed by cloudflared. The final 404 rule rejects unmatched hostnames. Public websites and the browser telemetry endpoint do not require an Access login; protection is chosen per application, not implied by using a tunnel.
OpenTelemetry and ClickStack
One telemetry pipeline
The websites send standard OTLP to an OpenTelemetry Collector. Separate receivers keep internal application telemetry apart from public browser input. ClickHouse and ClickStack provide storage and exploration, with HyperDX as the search interface.
Named browser and business occurrences use OpenTelemetry EventRecords: the top-level EventName distinguishes them from diagnostic logs. Semantic views separate those records in ClickHouse. The Collector is configured for 30-day application telemetry retention and a persistent delivery queue; ClickHouse's own diagnostic logs have shorter retention.
The agent environments also select the internal Collector. Configuration alone does not prove delivery: exporter validation requires fresh records, and correlation depends on propagated trace context. Cloud Logging and Cloud Monitoring supply the separate system-level view.
Browser visibility and privacy
Scroll horizontally to read the full table.
| Signal | What it helps explain |
|---|---|
| Navigation and engagement | What people read and explore: article opens, completion, and outbound links on andymeier.dev; service/project views and contact/scheduling clicks on meiermade.com. |
| Fetch traces | Request timing and failures, with same-origin trace-context propagation where supported. |
| JavaScript errors | Client-side problems that server logs alone cannot explain. |
| Web Vitals | Page experience, without backfilling a page load from before acceptance. |
A tab-scoped session ID links browser records; this is not session replay. Opt-in regions and unknown locations require acceptance. Other recognized locations use default-on analytics with an opt-out. A saved refusal applies in either mode, and visitors can withdraw through analytics settings. Collection is off in the normal local Watch workflow, independently of server observability.
The public path applies strict CORS, request-size and Cloudflare rate limits, event-name validation, and attribute allowlists. The Collector clears log bodies and span status messages; navigation URLs become paths and campaign attribution is constrained. Browser telemetry remains untrusted input, not proof of an authenticated action.
Agents as operators
Reusable skills let me and the agents search HyperDX, follow trace IDs, and run bounded read-only ClickHouse queries. Their Kubernetes identities can inspect workloads, events, and logs; their shared Google runtime principal has narrowly scoped viewer roles for cluster, logging, and monitoring context, not deployment authority.
Scroll horizontally to read the full table.
| Purpose | Credential path | Boundary |
|---|---|---|
| Deployment and application configuration | Secret Manager → Pulumi ESC → deployment | Selected secrets and short-lived cloud identity for the authorized environment. |
| Agent service commands | Caller-scoped 1Password vault → selected command → protected service | Resolve only the credentials needed by that subprocess; no new deployment secret for each tool. |
Cloudflare Access authenticates the perimeter. A HyperDX API key or ClickHouse database credential separately authorizes backend access. Reaching the service is not permission to read its data.
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.
Pulumi ESC composes stack outputs, configuration, and selected secrets for each environment. Its GCP login provider exchanges OpenID Connect identity for a temporary Google Cloud token. A dedicated service account can read only the authorized secrets; the repository receives no service-account key.
I keep resource modules small and ownership explicit. Pulumi calculates previews and records state; ESC resolves environment values when opened. This abridged configuration shows the login, secret lookup, and projection into Pulumi:
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
The pull request is the unit of change. Checks and review happen before merge; CI then builds the image, deploys through Pulumi, waits for readiness, and tests the resulting application.
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: <organization>/<project>/prod
command: <preview-or-up>Locally, the GitHub CLI and GitHub API give me and the agents the same pull requests, diffs, and check results. Production changes follow that review path rather than an unreviewed update from a workstation.
Intentional tradeoffs
A static host would be simpler for one website. I accept more moving parts because several websites and agents reuse them. The important choices are explicit:
Scroll horizontally to read the full table.
| Choice | Benefit | Cost accepted |
|---|---|---|
| Zonal GKE | Managed Kubernetes with a lower baseline than multi-zone redundancy. | A zone-level incident can interrupt every workload in the cluster. |
| Shared infrastructure | Reuse private ingress, deployment, identity, and observability conventions. | Shared capacity and a common failure boundary need attention. |
| Self-hosted ClickStack | One queryable store for application diagnostics and named events. | Maintain ClickHouse, MongoDB, persistent storage, queue capacity, and retention. |
| Provider dependencies | Google Cloud, Cloudflare, Pulumi, and GitHub remove operational work. | Replacing them takes real migration work, despite portable containers and OTLP. |
The value is not just that I can deploy another application. It is that I and my agents can use the same APIs, scoped tools, and telemetry to understand what it is doing.