Docs
Docker
Run the Proa image yourself, and own everything a platform would have done.
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.
# 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
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.
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
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:
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:
- Logs.
docker logsvanishes with the container. Ship to a collector before you need it, not after. - Disk. Monitor image storage and prune with a retention policy that preserves the images you need for rollback.
- Alerting. A Compose healthcheck marks a container unhealthy; it does not restart it. The restart policy handles process exits. Add monitoring and a recovery policy for a process that stays running but stops responding. See Docker restart policies.
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
- Deploy overview
- Pick a deployment target, then follow the guide for it.
- Railway
- Deploy a Proa app to Railway from a Dockerfile in about ten minutes.
- Coolify
- Deploy a Proa app to Coolify on your own VPS.
- Environment variables
- Configure a deployed Proa app without rebuilding the binary.
- CI / GitHub Actions
- Gate pull requests with proa check, then deploy from the same pipeline.