I run a bunch of small products, and most of the work now happens through coding agents. That changes what I want from deployment.

Clicking around in a deployment platform is the wrong interface for that. I want deployment to be visible in the repo, scriptable from a terminal, and predictable when several agents are working at once.

For my current setup, Kamal is enough.

The setup is one physical server

My current pattern is simple: one Hetzner server, Docker containers, Kamal configs in each repo, and PostgreSQL on the same box.

The setup is intentionally small: one solo operator running small products that need to stay cheap and understandable.

The repo evidence is plain:

  • MyOG.social deploys web, backend, and api roles to the same host.
  • TheBlue.social deploys web, backend, feed, and worker roles to the same host.
  • TheBlue’s AGENTS.md says it migrated from Render to Hetzner with Kamal 2 on April 8, 2026.
  • TheBlue shares that server and the PostgreSQL accessory with MyOG.social and AltCaption.com.
  • MyOG’s Kamal config defines the shared PostgreSQL accessory and the Vector logging accessory.

I don’t set up a new database server for every tiny product. MyOG was the first project in this setup, so it owns the shared server pieces. The newer projects follow the pattern instead of inventing their own.

MyOG was the first one

MyOG.social has the older Kamal setup in my repos. Its history shows the move in December 2025:

2025-12-20 Add Kamal 2 deployment configuration for Hetzner with PostgreSQL, Vector logging, SSL, and pre-rendering support
2025-12-19 Remove legacy render.yaml now using Kamal on Hetzner

The config is what I like about Kamal. The deployment state is a file:

service: myog

servers:
  web:
    hosts:
      - <server-ip>: frontend

  backend:
    hosts:
      - <server-ip>: backend
    proxy:
      ssl: true
      host: backend.myog.social
      app_port: 3001

  api:
    hosts:
      - <server-ip>: api
    proxy:
      ssl: true
      host: api.myog.social
      app_port: 3002

accessories:
  postgres:
    image: postgres:16-alpine
    host: <server-ip>

  vector:
    image: timberio/vector:latest-alpine
    host: <server-ip>

I trimmed that snippet, but the important parts are there: roles, host, proxy, app ports, accessories.

Kamal’s own docs describe the same model: roles live under servers, accessories are managed separately from the app, and the proxy routes traffic to the new container after the health check passes.

That maps well to how I want agents to work. If a deploy fails, the agent can read config/deploy.yml, compare it with the Dockerfile and runtime scripts, inspect logs, and make a repo change.

No special dashboard integration needed.

TheBlue Migrated Later

TheBlue.social moved later, after the MyOG setup had already proved itself.

Its AGENTS.md records the date directly:

Migrated from Render to Hetzner (Kamal 2) on 2026-04-08.
Shares the Hetzner server and Postgres accessory
with MyOG.social and AltCaption.com.

TheBlue is also a good example of the pattern growing without turning into a platform.

It started as a web/backend app, then picked up more moving parts: feed service, background workers, and a lot of pre-rendered SEO pages. The Kamal setup split with it:

config/deploy.yml          # web
config/deploy.backend.yml  # backend + workers
config/deploy.feed.yml     # custom feed service

The web deploy has Caddy serving the built frontend and host-mounted pre-rendered HTML releases. Backend deployment covers the API and workers. Feed has its own image and service.

Still the same idea: Docker images, Kamal configs, one server, explicit roles.

The deploy script detects changed areas and deploys the right targets:

scripts/push-and-deploy.sh
scripts/push-and-deploy.sh --force
kamal deploy --roles=web
kamal deploy -c config/deploy.backend.yml
kamal deploy -c config/deploy.feed.yml

It also handles a practical rule I care about: push to GitHub after deploy succeeds. If production deploy fails, origin/main should not advance as if everything shipped.

That rule matters more once agents are allowed to deploy. If production deploy fails, I want the repo state to say that clearly too.

Why this works with coding agents

Coding agents are good at text, diffs, commands, and logs. Kamal gives them exactly that.

A deployment problem usually becomes one of these:

  • a missing secret name in .kamal/secrets or config/deploy.yml
  • a bad health check path
  • a Docker build issue
  • a runtime script that starts the wrong process
  • a frontend build-time env var that was treated like a runtime env var
  • a role split that needs a different deploy config

An agent can inspect those repo problems.

My project AGENTS.md files tell agents how to deploy, which package manager to use, how long web builds usually take, what not to print, and when to run the deploy in tmux with a callback.

That last part is important because deploys are long-running enough to be annoying inside a chat turn. The TheBlue instructions say web deploys have taken about 6.7 minutes, and that Vite can sit quietly at rendering chunks... for several minutes. The point of writing that down is to stop an agent from killing a deploy just because nothing printed for a while.

I want the agent to know that before it touches the system.

The deployment lock handles the shared server

One server is simple, but RAM is finite.

If I have three coding agents working on three products, I do not want all three running Kamal deploys against the same server at the same time.

Agent Control handles that with a shared deploy lock:

deploy:hetzner-main

The rule is: acquire the lock before starting a Hetzner/Kamal deploy, keep it through verification, then release it. If another session already has it, the new deploy waits.

The actual workflow is still the normal project deploy. The lock wraps it.

Each repo still owns its deploy command. Agent Control serializes access to the shared server.

So the flow becomes:

agent finishes change
agent runs checks
agent commits
agent asks for deploy lock
Agent Control queues it if another deploy is running
agent deploys in tmux when lock is acquired
agent verifies production
agent releases lock
agent reports back

This is the part that makes one server work with agent-driven development. The server stays simple, but the deployment jobs are serialized.

Where I Draw the Line

One server has limits.

If the server is down, multiple products are affected. More capacity means moving pieces around. A product that grows a lot may need to leave the shared setup.

That is fine for where these products are now.

Most small products do not need a platform on day one. They need backups, logs, health checks, repeatable deploys, and a setup the owner understands. My setup has those pieces without making me operate a deployment platform.

Kamal is not magic. It still uses Docker, SSH, a registry, env files, health checks, and a proxy. The useful part is that those pieces are explicit and close to the code.

That fits my work better than a sprawling platform.

What I Keep Reusing

The pattern I keep copying into newer projects:

  • one Kamal config per deployable boundary when the app grows
  • web, backend, feed, or worker roles instead of one giant container
  • .kamal/secrets reading from .env.kamal, without committing values
  • app-specific AGENTS.md deploy notes
  • push/deploy scripts that detect changed services
  • tmux for deploys that take minutes
  • Agent Control callbacks after long-running commands
  • deploy:hetzner-main for shared-server deploy serialization

For my current small-products life, this is enough infrastructure.

I can tell a coding agent to fix something, run the checks, deploy it, verify it, and report back. The deploy path is plain text the agent can read and reason about.