Docs

Docker

Run the Proa image yourself, and own everything a platform would have done.

Open Markdown

Proa produces one release binary and a public/ directory. This page packages both into an image and runs it on hardware you control.

Read what self-managed Docker costs you first. Take this path when you are running Proa beside an existing app, deploying into Kubernetes, Nomad, or ECS, or working somewhere a managed platform is not an option. For a plain public site, Railway or Coolify does all of this for you.

Build the image

Multi-stage: install the Proa CLI beside the Rust toolchain, build through the linked pipeline, run from a slim base. Proa dependencies resolve from crates.io without credentials. proa new site --deploy docker writes deployment files for you. If its pinned binary archive is unavailable, use the Cargo-based Dockerfile below. It reads the CLI version from the generated proa.lock.json, so the build stays pinned to your project's tooling without a version copied from these docs.

Dockerfile
# syntax=docker/dockerfile:1.7

FROM rust:1.93-bookworm AS build
WORKDIR /app
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git jq \
    && rm -rf /var/lib/apt/lists/*

# Install the CLI version recorded when this project was scaffolded.
COPY proa.lock.json /tmp/proa.lock.json
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    proa_version="$(jq -er '.scaffold.cliVersion' /tmp/proa.lock.json)" \
    && cargo install proa-cli --version "=$proa_version" --locked

# Install the checksum-verified standalone Tailwind CLI.
RUN curl --proto '=https' --tlsv1.2 -fsSL https://proa.so/install.sh \
    | sh -s -- --tailwind-only --to /usr/local/bin --no-modify-path --yes

COPY . .

RUN --mount=type=cache,target=/usr/local/cargo/registry \
    proa build --locked --artifact-out /tmp/proa-app

FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl \
    && rm -rf /var/lib/apt/lists/*

COPY --from=build /tmp/proa-app /usr/local/bin/my-site
COPY --from=build /app/public ./public

ENV PROA_HOST=0.0.0.0
ENV PORT=3000
ENV RUST_LOG=info

EXPOSE 3000
CMD ["my-site"]

The first image build compiles your project's pinned CLI from crates.io, which takes several minutes. Docker caches that installation layer separately from your application source. The installer handles only Tailwind and verifies its standalone binary's checksum. This source-build path also works with the Bookworm base images.

proa build runs the configured asset build and the linked RSJS pipeline, then copies the attested binary to --artifact-out. A plain cargo build --release skips the asset build and fails at link time on any crate with islands. The generated server reads PROA_HOST and PORT and serves public/ relative to its working directory, which is why the runtime stage sets WORKDIR /app and copies public/ beside it.

docker build -t my-site:$(git rev-parse --short HEAD) .

Use commit-qualified tags or image digests, and retain the previous image for rollback. Tags can be overwritten; a digest identifies the exact image.

Build in CI rather than on the production box. Rust release builds want CPU and RAM that your web server should be spending on requests.

Run it

compose.yaml
services:
  web:
    image: ghcr.io/you/my-site:abc1234
    restart: unless-stopped
    environment:
      PROA_HOST: 0.0.0.0
      PORT: 3000
      RUST_LOG: info
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/healthz"]
      interval: 10s
      timeout: 2s
      retries: 3
      start_period: 5s
    ports:
      - "127.0.0.1:3000:3000"

Bind to 127.0.0.1:3000, not 0.0.0.0:3000. Publishing to all interfaces bypasses the host firewall on most Docker installs and exposes the app directly.

Beside an existing app

This is the shape the Rails and Django / Flask migrations describe: Proa renders the routes you have moved, your existing app serves the rest, and one proxy routes between them.

compose.yaml
services:
  legacy:
    image: ghcr.io/you/legacy-app:def5678
    expose: ["8000"]

  proa:
    image: ghcr.io/you/my-site:abc1234
    expose: ["3000"]
    environment:
      PORT: 3000

  proxy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
Caddyfile
example.com {
    handle /rooms/* {
        reverse_proxy proa:3000
    }
    handle {
        reverse_proxy legacy:8000
    }
}

Move one path prefix at a time. The proxy config is the migration progress bar.

TLS and the rest of the platform

Caddy handles certificates and renewal with no configuration, which is why it appears above. nginx works too, with certbot and a renewal timer you now maintain.

Either way, preserve forwarding headers or the app cannot see the real client:

/etc/nginx/conf.d/my-site.conf
proxy_set_header Host              $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP         $remote_addr;

Do not cache dynamic HTML at the proxy until you have route-level rules. See Page caching.

Three more things a platform would have handled, in the order they bite:

Ship a new version without downtime

When docker compose up -d recreates a single application container, it does not provide a readiness-gated traffic swap. Use parallel containers and a proxy cutover when that interruption is unacceptable.

Start the new version alongside, prove it healthy, then move traffic:

NEW=abc1234
docker run -d --name my-site-$NEW -p 127.0.0.1:3001:3000 \
  -e PORT=3000 -e PROA_HOST=0.0.0.0 ghcr.io/you/my-site:$NEW

healthy=false
for i in $(seq 30); do
  if curl --max-time 2 -fsS http://127.0.0.1:3001/healthz; then
    healthy=true
    break
  fi
  sleep 1
done
if [ "$healthy" != true ]; then
  docker rm -f "my-site-$NEW"
  echo "unhealthy, aborting"
  exit 1
fi

# flip the proxy upstream 3000 -> 3001, reload, then retire the old container

Rollback uses the same sequence with the previously retained image tag or digest.

For more than one host, this is the point where Kubernetes, Nomad, or ECS stops being overkill.

Verify before you deploy

docker build -t my-site:test .
docker run --rm -p 127.0.0.1:3000:3000 -e PORT=3000 my-site:test
curl -fsS http://127.0.0.1:3000/healthz
curl -fsS http://127.0.0.1:3000/static/styles.css -o /dev/null
docker image inspect my-site:test --format '{{.Size}}'                  # inspect the final image size

Inspect the image size and layers to confirm that the runtime stage contains only the application, assets, and runtime dependencies.

Next steps

Search

Type at least 2 characters