← All notes

Writing a Dockerfile for an AppVM: nine principles

An AppVM boots an ordinary OCI image as its own machine, so your Dockerfile skills carry over directly. Nine principles for writing one that behaves, each following from a single idea: the image is a machine's root filesystem and its only service.

TL;DR — Write the image as a machine’s root filesystem and its only service, and everything else follows. Nine principles, grouped by what each one shapes.

An AppVM takes an ordinary OCI image and boots it as its own virtual machine. The happy news is how much carries over unchanged: standard base images, multi-stage builds, ENTRYPOINT, CMD, ENV, WORKDIR, USER, and HEALTHCHECK all behave the way you already expect. Your Dockerfile skills are the right skills.

What follows is the small set of principles that make an image feel native here. They are not workarounds. Each is a direct consequence of one idea, and once you hold that idea you can derive the rest yourself.

The idea everything follows from

A container image is a stack of layers that a runtime mounts, overlays, and runs a process in, on a kernel shared with everyone else.

An AppVM image is flattened into a filesystem, written onto a virtual disk, and booted with its own kernel. It is a machine’s root filesystem, and the workload is that machine’s reason to exist.

So write it the way you would provision a small, single-purpose server, and package it the way you already package containers. That is the whole shift, and it is a pleasant one, because a single-purpose server is a much simpler thing to reason about than a container in an orchestrator.

SHAPE THE PROCESS MODEL
  1. 01 Give the service its own address the VM is a host on your network
  2. 02 Let the service be the main process its exit is the machine's exit
  3. 03 Write for a service, not a session no terminal is attached
SHAPE THE CONTENTS
  1. 04 Ship configuration inside the image nothing is mounted in at runtime
  2. 05 Pack the tools your future self needs the VM is the unit of debugging
  3. 06 Keep credentials out of the file the build spec can be read back
SHAPE THE RELEASE
  1. 07 Build lean, every instance carries a copy bytes multiply, layers do not
  2. 08 Make the health check mean restart me recovery reboots the machine
  3. 09 Prove it runs before you convert it detached, no terminal, still up

Shape the process model

1. Give the service its own address

Every AppVM has its own IP address on your network. That is the gift here: no published ports, no port collisions between workloads, no reverse proxy on the host fanning traffic by hostname. The VM is a host, so treat it like one and let the service listen where the network can reach it.

Concretely, configure the service to bind all interfaces, exactly as you would on a bare metal box or a normal VM. EXPOSE is documentation only, because there is no mapping layer to declare anything to.

Some applications add a second condition on top of the bind, and it is worth checking for early. DeepSeek Harness, the subject of the companion walkthrough, also inspects the caller’s Host header on part of its own API and accepts only a loopback value there, whichever address it is listening on. No bind setting satisfies that, so the walkthrough puts a small nginx inside the machine to present each request as loopback. Read the application’s deployment story first and you will know which of these you are dealing with before you write a line.

2. Let the service be the main process

An init process inside the guest is PID 1 and supervises your workload as its child, so you get zombie reaping and clean signal forwarding for free. This is docker run --init behaviour, always on, at no cost to you.

The principle: make your real service the process the init watches, by finishing the entrypoint with exec. Then the machine’s lifecycle and the service’s lifecycle are the same thing. If the service dies, the machine stops, and you find out.

Helpers run beside it. Process managers such as tini, dumb-init, s6, and supervisord all work, and a plain shell loop is often enough:

COPY <<'SH' /usr/local/bin/entrypoint
#!/bin/sh
set -eu

helper() {
    # A stop signal reaches the whole process group, so leave on it rather than
    # restarting during shutdown.
    trap 'exit 0' TERM INT
    while :; do
        my-helper || true
        echo "[helper] exited; restarting in 5s" >&2
        sleep 5 || exit 0
    done
}

helper &

exec my-app --port 3080 "$@"
SH

Keeping the service as the watched process is what preserves the signal. Put everything under a manager instead and the manager survives your service dying, so the machine keeps reporting itself up with nothing serving. The shape above gives you supervision for the helper and honesty about the thing that matters.

Init systems are the one exception: an image whose entrypoint is systemd or /sbin/init is rejected at import, because the workload runs as a supervised process rather than booting an init system. Reach for a process manager, not an init.

3. Write for a service, not a session

The workload starts with no terminal attached and no stdin. Design for that and you get a useful property: a service that fails to start exits, and the exit is visible immediately rather than being masked by something waiting on input.

In practice this means the entrypoint should be a real service. A base image whose entrypoint is an interactive shell or a REPL will start and stop at once, which is the system reporting accurately that nothing is running. When you do want to poke around inside a base image, that is what an explicit keepalive mode is for.

Shape the contents

4. Ship configuration inside the image

Nothing is mounted in from the host at runtime, which keeps the machine boundary intact and has a nice side effect: the image is complete. What you built is what runs, with no external file the deployment has to remember to provide.

Heredocs make this pleasant, and they need no build context at all:

COPY <<'YML' /etc/my-app/config.yml
server:
  host: "0.0.0.0"
  port: 3080
YML

That matters when you submit a Dockerfile through an API with nothing attached, where COPY ./file has nothing to copy from and a heredoc always works.

Genuinely per-instance material has its own channels: config files, secret files, tmpfs mounts, and data volumes, all delivered by the platform. The question to ask of each file is simply whether it belongs to the image or to the instance.

5. Pack the tools your future self needs

Here is where AppVM inverts a container habit, and for a good reason. The VM is your unit of debugging. There is no sidecar to attach and no second image to exec from, and the web terminal runs /bin/sh from this filesystem.

So ship a shell. Ship curl. Ship iproute2 or net-tools. A few megabytes buys you the ability to open a terminal on a running machine and answer the questions that matter at three in the morning: what address do I have, what am I listening on, is the service actually answering. The walkthrough does exactly that with ip -brief addr, ss, and a loopback curl, and those four packages are the reason it can.

An image with no shell is a machine you can start and stop but never interrogate.

6. Keep credentials out of the file

The build spec is stored with the template and can be read back through the API and the UI, so treat the Dockerfile as readable by anyone who can see the platform. That is the right assumption for any build file, and here it is concrete.

Pass credentials as instance-level secret environment variables or secret files instead. Those are encrypted at rest, decrypted only on the way into the guest, and redacted from views and logs. The image stays generic and publishable, and the same template serves every environment.

Shape the release

7. Build lean, because every instance carries a copy

The template is flattened and each instance receives a full copy of it rather than sharing a layer. Layer count stops mattering at runtime, and total size is what multiplies.

This makes multi-stage builds pay better here than on Docker Engine. Compilers, headers, and package caches that you would tolerate in a container image are worth moving to a builder stage, because you are paying for them once per machine rather than once per host.

8. Make the health check mean “restart me”

HEALTHCHECK is wired to real recovery: with a restart policy set, sustained unhealthy reboots the machine. That is a genuinely useful lever, and it rewards a probe that describes the condition where rebooting is the correct cure.

Probe the thing whose failure should restart the VM, end to end where you can. If a component already heals itself, let it, and keep it out of the probe. One mechanical note: HEALTHCHECK survives only in docker-format manifests, since OCI-format images drop the field.

9. Prove it runs before you convert it

Build the image with plain podman build on the host first, then run it the way the platform will:

podman run -d --name check my-image
podman logs check

Detached, no terminal. That is a close approximation of AppVM conditions and it takes seconds, where the conversion pipeline is a slow loop that reports failures as an operation error rather than the build output you were reading.

Every real bug in the DeepSeek Harness image was caught this way: a native module needing a compiler, a host networking problem masquerading as DNS, and a bash-ism in a script the guest’s sh would not run. None of them needed a VM to find.

Facts that follow

A few smaller behaviours make sense immediately once the model is in place.

/tmp and /run are fresh each boot, with the image’s contents copied up into them, so a pre-seeded skeleton survives while the semantics stay “new every boot”. The root filesystem is writable and persists across stop and boot, because it is a disk rather than an overlay, and it is reset when you roll the machine onto a new build, which is why durable state belongs on a data volume. Images are linux/amd64, checked at import.

The checklist

Before submitting a Dockerfile as an AppVM template:

  • The service binds where the network can reach it, and you have checked whether the app restricts callers by authority.
  • The entrypoint is a real service, and it is execed so it stays the main process.
  • Helpers, if any, run beside it rather than above it.
  • Configuration is in the image; per-instance material uses its own channel.
  • There is a shell and at least one network tool.
  • Credentials come from secret environment variables or secret files.
  • Anything only the build needed stayed in a builder stage.
  • The health check describes a condition where rebooting is the right cure.
  • You ran it detached, with no terminal, and watched it stay up.

Nine principles, one idea. The image is a machine’s root filesystem and its only service, and the rest of your Dockerfile knowledge transfers directly, which is the point. The machine is the boundary, and the container image is a very good way to fill it.

For a complete, working example of every principle above, see Run DeepSeek Harness as an AppVM, step by step.

KEEP READING
After Apple, Docker agrees that AI agents belong in their own machine Aug 16 Run DeepSeek Harness as an AppVM, step by step Aug 15