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. The GitHub token that fetches Proa's git dependencies stays in the build stage, which never ships. proa new site --deploy docker writes this Dockerfile for you; the version below is the same shape, using the installer script for the CLI.

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 \
    && rm -rf /var/lib/apt/lists/*

# Install a pinned, checksum-verified Proa CLI and the Tailwind binary.
ARG PROA_VERSION=0.1.1
RUN curl --proto '=https' --tlsv1.2 -fsSL https://proa.so/install.sh \
    | PROA_VERSION=$PROA_VERSION PROA_INSTALL_DIR=/usr/local/bin PROA_TAILWIND=yes PROA_YES=1 sh

COPY . .

# Fetch Proa git dependencies through the git CLI. While the Proa repository
# is private, pass a GitHub token with read access as Docker secret
# `github_token`; drop the secret once the repository is public.
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true
RUN --mount=type=secret,id=github_token \
    --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    set -eu; \
    export GIT_CONFIG_COUNT=1; \
    export GIT_CONFIG_KEY_0=credential.https://github.com.helper; \
    export GIT_CONFIG_VALUE_0='!f() { if [ "$1" = get ] && [ -s /run/secrets/github_token ]; then printf "username=x-access-token\npassword=%s\n" "$(cat /run/secrets/github_token)"; fi; }; f'; \
    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"]

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 --secret id=github_token,env=GITHUB_TOKEN -t my-site:$(git rev-parse --short HEAD) .

Tag with the commit SHA, never latest. Rollback means pointing at the previous tag. Without immutable tags you have nothing to point at.

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, and it keeps the GitHub token off the host entirely.

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

docker compose up -d stops the old container before the new one is ready. That is a visible outage on every deploy.

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

for i in $(seq 30); do
  curl -fsS http://127.0.0.1:3001/healthz && break
  sleep 1
done || { docker rm -f my-site-$NEW; echo "unhealthy, aborting"; exit 1; }

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

Rollback is the same sequence with the previous SHA. Which is exactly why the tag has to be a SHA.

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

Verify before you deploy

docker build --secret id=github_token,env=GITHUB_TOKEN -t my-site:test .
docker run --rm -p 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 run --rm my-site:test env | grep -c GITHUB_TOKEN   # expect 0
docker image inspect my-site:test --format '{{.Size}}'                  # expect ~100MB, not ~2GB

The last line catches a single-stage Dockerfile, which ships your entire Rust toolchain and source to production.

Next steps

Search

Type at least 2 characters