Hero Image

Vaultwarden Secure Setup AI assisted rebuild

Making Vaultwarden Updates Reliable: A Hardened SQLite-Only Build

I originally did this by hand with a series of patched up scripts because the original vaultwarden had several things I didn't like

  1. It ran as local root in the container
  2. It didn't have a durable container
  3. it didn't switch to a non-privileged user within the container
  4. It didn't have a read-only filesystem
  5. It didn't drop unnecessary privileges.

This worked fine, but it was a custom patch against the original source with hand patching, which was quite difficult to maintain. So I did do some work with the ai to help make it better. This part until I actually indicate, is written by "ME" a lot of the blog and the uninteresting how-to docs are written by the stochastic parrot.

This makes using it MUCH easier, I can build locally, copy the 'built' files to the remote system and import them into docker using a tar.zst file.

A few tips:

  1. Freeze your bitwarden client they like to update without telling me
  2. Keep your vaultwarden server well maintained
  3. Serve on ipv6 if possible FAR fewer probes

----------------BEGIN STOCHASTIC PARROT------------------------------

Vaultwarden worked well, but the old update process was cumbersome enough that upgrades were easy to postpone. That is a poor fit for password-management infrastructure.

The new workflow keeps things simple: build an exact Vaultwarden release on a fast host, apply a small hardening patch, compile only SQLite support, verify the image, then export it as a compressed Docker archive for the production host to load.

No private registry is required, no Docker socket is exposed to helper containers, and production never needs to compile Vaultwarden.

This article uses Vaultwarden 1.37.1. Later versions are selected on the command line, and the build fails unless the exact upstream Git tag exists.

Important: Review the scripts before use. Change the example domain and verify the existing Docker volume name before deploying.

What changed, and why

The previous Alpine 3.23.4 image produced 62 Trivy findings: 30 low, 24 medium, and 8 high. The freshly rebuilt image produced zero known vulnerabilities with the Trivy database used during testing.

That result is only a snapshot. The real improvement is making rebuilds cheap enough to perform routinely.

The build also removes unused database support. Instead of Vaultwarden’s default:


sqlite,mysql,postgresql,enable_mimalloc

this deployment uses:


sqlite,enable_mimalloc

That means less code, fewer dependencies, and a smaller attack surface.

The workflow

The fast build host does this:


exact Vaultwarden tag

        |

        +--> clean upstream checkout

        +--> verify runtime Alpine baseline

        +--> apply small hardening patch

        +--> lint and BuildKit checks

        +--> source build (SQLite only)

        +--> verify image invariants

        +--> docker image save | zstd

        +--> SHA-256 sidecar + immediate verification

The production host does this:


.tar.zst + .sha256

        |

        +--> SHA-256 verify

        +--> docker image load

        +--> verify image invariants

        +--> verify expected /data volume

        +--> deploy with Compose --no-build --pull never

        +--> verify running UID, read-only rootfs, caps, NNP, volume, health

A crucial detail is that the exported .tar.zst contains only the Docker image. It does not contain the Vaultwarden database, attachments, keys, or any Docker volume data.

Prerequisites

On the build host expectations include:

  • Docker Engine with Docker Compose v2 and BuildKit
  • Fish
  • Git
  • zstd
  • GNU sha256sum
  • normal Unix tools used by the scripts grep, patch, mv, etc.)

The lint gate runs Hadolint from a pinned container image, so Hadolint itself does not need to be installed locally.

The example is intentionally pinned to linux/amd64. If your production host is ARM, adapt the platform and archive naming/validation consistently rather than simply deleting the architecture checks.

Directory layout

Keep the permanent control plane under /opt:


/opt/vaultwarden-control/

├── .env

├── .gitignore

├── .hadolint.yaml

├── compose.yaml

├── deploy.fish

├── image-cache.fish

├── install-completions.fish

├── lint.fish

├── security.patch

├── completions/

│   ├── deploy.fish.fish

│   └── image-cache.fish.fish

├── dist/               # generated locally; not source-controlled

└── src/                # upstream checkout; generated locally

Create it with:


sudo mkdir -p /opt/vaultwarden-control/completions

sudo chown -R $USER:$USER /opt/vaultwarden-control

cd /opt/vaultwarden-control

The following files are the complete control-plane source. The only deployment-specific value replaced is the domain, which is shown as https://vault.example.com.

1. compose.yaml

The SQLite-only build decision is the DB build argument. The runtime image name remains versioned locally as vaultwarden-secure:<version>.


name: vaultwarden

# Image bootstrap is intentionally host-side: ./deploy.fish invokes

# ./image-cache.fish before `docker compose up`. Compose itself cannot load a

# Docker archive into the daemon without giving a helper container control of

# the Docker socket. pull_policy: never keeps deployment local/fail-closed.

services:

  vaultwarden:

    image: "vaultwarden-secure:${VW_VERSION:-1.37.1}"

    platform: linux/amd64

    pull_policy: never

    # Canonical source rebuild definition. deploy.fish prepares ./src at the

    # exact requested upstream tag and applies security.patch before building.

    build:

      context: ./src

      dockerfile: docker/Dockerfile.alpine

      args:

        VW_VERSION: "${VW_VERSION:-1.37.1}"

        VW_GIT_COMMIT: "${VW_GIT_COMMIT:-unknown}"

        # Experimental variant: compile only the SQLite backend plus mimalloc.

        DB: "sqlite,enable_mimalloc"

    container_name: vaultwarden

    restart: unless-stopped

    stop_grace_period: 30s

    # SECURITY: Enforce the runtime identity independently of Dockerfile USER.

    user: "2000:2000"

    read_only: true

    environment:

      DOMAIN: "https://vault.example.com"

      SIGNUPS_ALLOWED: "false"

      ROCKET_PORT: "8080"

      TMPDIR: "/tmp"

    volumes:

        - vaultwarden_data:/data

    # Writable scratch only; root filesystem remains read-only.

    tmpfs:

        - /tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777

    ports:

        - "127.0.0.1:3001:8080"

    security_opt:

        - no-new-privileges:true

    cap_drop:

        - ALL

    logging:

      driver: syslog

      options:

        syslog-facility: daemon

        tag: vaultwarden

volumes:

  vaultwarden_data:

    # Exact existing volume. Compose must never silently create a new vault.

    external: true

    name: vaultwarden_vaultwarden_data

Before using this, change:


DOMAIN: "https://vault.example.com"

to your real external Vaultwarden URL.

Also verify this is really the name of your existing production data volume:


name: vaultwarden_vaultwarden_data

The scripts deliberately fail if the existing container or deployment points /data somewhere else.

2. security.patch

This patch modifies only Vaultwarden's upstream Alpine runtime stage. The build scripts apply it with git apply --check first. If a future upstream Dockerfile changes enough that the patch no longer applies cleanly, the build stops and requires review instead of guessing.


diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine

index baa4c97..fdbbc66 100644

--- a/docker/Dockerfile.alpine

+++ b/docker/Dockerfile.alpine

@@ -128,31 +128,48 @@ RUN source /env-cargo && \

 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742

 FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24

+ARG VW_VERSION

+ARG VW_GIT_COMMIT

+LABEL org.opencontainers.image.source="https://github.com/dani-garcia/vaultwarden" \

+      org.opencontainers.image.version="${VW_VERSION}" \

+      org.opencontainers.image.revision="${VW_GIT_COMMIT}"

+

 ENV ROCKET_PROFILE="release" \

     ROCKET_ADDRESS=0.0.0.0 \

-    ROCKET_PORT=80 \

+    ROCKET_PORT=8080 \

     SSL_CERT_DIR=/etc/ssl/certs

-# Create data folder and Install needed libraries

+# SECURITY: Create a dedicated non-root runtime identity.

+RUN addgroup -S vaultwarden -g 2000 && \

+    adduser -S vaultwarden -G vaultwarden -u 2000

+

+# Create data folder and install needed libraries.

+# SECURITY: Keep /data owned by the runtime identity and remove SUID/SGID bits.

 RUN mkdir /data && \

     apk --no-cache add \

         ca-certificates \

         curl \

         openssl \

-        tzdata

+        tzdata && \

+    chown -R 2000:2000 /data && \

+    find / -xdev -type f \( -perm -4000 -o -perm -2000 \) -exec chmod a-s {} +

 VOLUME /data

-EXPOSE 80

+EXPOSE 8080

 # Copies the files from the context (Rocket.toml file and web-vault)

 # and the binary from the "build" stage to the current stage

 WORKDIR /

 COPY docker/healthcheck.sh docker/start.sh /

+RUN chmod 0555 /healthcheck.sh /start.sh

 COPY --from=vault /web-vault ./web-vault

 COPY --from=build /app/target/final/vaultwarden .

+# SECURITY: Vaultwarden itself must never run as root.

+USER 2000:2000

+

 HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]

 CMD ["/start.sh"]

3. deploy.fish

This is the main operator command. Its normal decision order is:

  1. Use an already-loaded image only if it passes verification.
  2. Otherwise import a matching verified archive from dist/.
  3. Otherwise build from the exact upstream tag, unless --no-build was specified.

--build-only is the useful fast-builder mode: it never inspects or modifies the production data volume and never starts Vaultwarden.


#!/usr/bin/env fish

function die

    echo "ERROR: $argv" >&2

    exit 1

end

function usage

    echo "Usage: deploy.fish [OPTIONS] [VERSION]"

    echo

    echo "Options:"

    echo "  --build       Force a source rebuild even if the image/archive already exists."

    echo "  --build-only  Build, verify and export; do not inspect /data or deploy."

    echo "  --no-build    Never build; require an existing image or verified dist archive."

    echo "  --no-export   Do not export a .tar.zst after a source build."

    echo "  -h, --help    Show this help."

end

set -l script_file (status --current-filename)

set -l control_dir (cd (dirname "$script_file"); and pwd)

cd "$control_dir"; or die "Could not enter $control_dir"

set -l repo_url "https://github.com/dani-garcia/vaultwarden.git"

set -l src_dir "$control_dir/src"

set -l compose_file "$control_dir/compose.yaml"

set -l patch_file "$control_dir/security.patch"

set -l env_file "$control_dir/.env"

set -l image_cache "$control_dir/image-cache.fish"

set -l volume_name "vaultwarden_vaultwarden_data"

set -l dockerfile "$src_dir/docker/Dockerfile.alpine"

set -l default_version "1.37.1"

argparse 'h/help' 'b/build' 'build-only' 'no-build' 'no-export' -- $argv

or begin

    usage >&2

    exit 2

end

if set -q _flag_help

    usage

    exit 0

end

set -l force_build 0

set -l build_only 0

set -l no_build 0

set -l export_after_build 1

set -q _flag_build; and set force_build 1

set -q _flag_build_only; and begin

    set build_only 1

    set force_build 1

end

set -q _flag_no_build; and set no_build 1

set -q _flag_no_export; and set export_after_build 0

test $force_build -eq 1 -a $no_build -eq 1; and die "--build/--build-only cannot be combined with --no-build"

test $build_only -eq 1 -a $export_after_build -eq 0; and die "--build-only requires export; do not combine it with --no-export"

test (count $argv) -le 1; or die "Expected at most one VERSION argument"

for command_name in git docker grep cat tail mv sleep fish

    type -q $command_name; or die "Required command not found: $command_name"

end

docker compose version >/dev/null 2>&1; or die "Docker Compose v2 is required"

docker info >/dev/null 2>&1; or die "Docker daemon is unavailable"

test -f "$compose_file"; or die "Missing compose.yaml"

test -f "$patch_file"; or die "Missing security.patch"

test -x "$image_cache"; or die "Missing executable image-cache.fish"

# Reuse the last selected release as the interactive default.

if test -f "$env_file"

    for line in (cat "$env_file")

        if string match -rq '^VW_VERSION=' -- "$line"

            set -l candidate (string replace 'VW_VERSION=' '' -- "$line")

            if string match -rq '^[0-9]+\.[0-9]+\.[0-9]+$' -- "$candidate"

                set default_version "$candidate"

            end

            break

        end

    end

end

set -l vw_version ""

if test (count $argv) -eq 1

    set vw_version "$argv[1]"

else

    read -P "Vaultwarden version [$default_version]: " vw_version

    if test -z "$vw_version"

        set vw_version "$default_version"

    end

end

string match -rq '^[0-9]+\.[0-9]+\.[0-9]+$' -- "$vw_version"; or die "Version must look like 1.37.1"

set -l image_name "vaultwarden-secure:$vw_version"

set -l need_build 0

set -l commit ""

function write_env_from_image --argument-names selected_version selected_env_file

    set -l selected_image "vaultwarden-secure:$selected_version"

    set -l revision (docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$selected_image" 2>/dev/null)

    string match -rq '^[0-9a-f]{40}$' -- "$revision"; or return 1

    printf 'VW_VERSION=%s\nVW_GIT_COMMIT=%s\n' "$selected_version" "$revision" > "$selected_env_file.tmp"; or return 1

    mv "$selected_env_file.tmp" "$selected_env_file"; or return 1

end

if test $force_build -eq 0

    echo "[image] Looking for $image_name locally or in ./dist..."

    fish "$image_cache" ensure "$vw_version"

    set -l ensure_status $status

    switch $ensure_status

        case 0

            write_env_from_image "$vw_version" "$env_file"; or die "Could not persist provenance from $image_name"

            set commit (docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image_name" 2>/dev/null)

            echo "      Source build skipped; verified image is ready."

        case 10

            if test $no_build -eq 1

                die "No local image or matching archive exists for $image_name, and --no-build was requested"

            end

            set need_build 1

        case '*'

            die "Image/archive verification failed; refusing to build over an untrusted cache state"

    end

else

    set need_build 1

end

if test $need_build -eq 1

    echo "[source] Preparing exact upstream Vaultwarden tag $vw_version..."

    if test -d "$src_dir/.git"

        set -l origin_url (git -C "$src_dir" remote get-url origin 2>/dev/null)

        or die "Could not read src/ Git origin"

        string match -q '*github.com/dani-garcia/vaultwarden.git' -- "$origin_url"; or die "src/ is not the expected Vaultwarden upstream repository"

    else

        if test -e "$src_dir"

            die "$src_dir exists but is not a Git checkout; move or remove it first"

        end

        git clone --filter=blob:none "$repo_url" "$src_dir"; or die "Could not clone Vaultwarden upstream"

    end

    git -C "$src_dir" fetch --prune --force --tags origin; or die "Could not fetch upstream tags"

    git -C "$src_dir" rev-parse --verify --quiet "refs/tags/$vw_version^{commit}" >/dev/null; or die "Upstream tag $vw_version does not exist"

    git -C "$src_dir" reset --hard HEAD >/dev/null; or die "Could not reset upstream checkout"

    git -C "$src_dir" clean -fdx >/dev/null; or die "Could not clean upstream checkout"

    git -C "$src_dir" checkout --detach "refs/tags/$vw_version" >/dev/null; or die "Could not check out tag $vw_version"

    git -C "$src_dir" clean -fdx >/dev/null; or die "Could not clean selected checkout"

    set commit (git -C "$src_dir" rev-parse HEAD)

    test -n "$commit"; or die "Could not resolve selected release commit"

    echo "      Source commit: $commit"

    echo "[source] Checking upstream Alpine runtime baseline..."

    test -f "$dockerfile"; or die "Upstream docker/Dockerfile.alpine is missing"

    set -l alpine_version (grep -Eo 'docker\.io/library/alpine:[0-9]+\.[0-9]+' "$dockerfile" | tail -n 1 | string replace 'docker.io/library/alpine:' '')

    test -n "$alpine_version"; or die "Could not determine Alpine runtime version"

    set -l alpine_parts (string split '.' "$alpine_version")

    test (count $alpine_parts) -ge 2; or die "Could not parse Alpine version '$alpine_version'"

    set -l alpine_major $alpine_parts[1]

    set -l alpine_minor $alpine_parts[2]

    if test $alpine_major -lt 3; or test $alpine_major -eq 3 -a $alpine_minor -lt 24

        die "Vaultwarden $vw_version uses Alpine $alpine_version; minimum accepted runtime is Alpine 3.24"

    end

    echo "      Runtime base: Alpine $alpine_version"

    echo "[source] Applying local non-root hardening with git apply..."

    git -C "$src_dir" apply --check "$patch_file"; or die "security.patch does not apply cleanly to Vaultwarden $vw_version; review required"

    git -C "$src_dir" apply "$patch_file"; or die "Could not apply security.patch"

    printf 'VW_VERSION=%s\nVW_GIT_COMMIT=%s\n' "$vw_version" "$commit" > "$env_file.tmp"; or die "Could not write temporary .env"

    mv "$env_file.tmp" "$env_file"; or die "Could not update .env"

    echo "[lint] Running pre-build lint/security gate..."

    fish "$control_dir/lint.fish"; or die "Pre-build lint gate failed"

    echo "[build] Building $image_name from source through Compose..."

    docker compose -f "$compose_file" build --pull vaultwarden; or die "Vaultwarden image build failed"

    echo "[verify] Verifying built image..."

    fish "$image_cache" verify "$vw_version"; or die "Built image verification failed"

    if test $export_after_build -eq 1

        echo "[export] Writing portable Docker image archive..."

        fish "$image_cache" export "$vw_version"; or die "Image export failed"

    end

end

if test $build_only -eq 1

    echo "Build-only operation complete."

    echo "      Release: $vw_version"

    echo "      Commit:  $commit"

    echo "      Image:   $image_name"

    echo "      Archive: "(fish "$image_cache" path "$vw_version")

    exit 0

end

# Runtime/deployment operations begin here. A pure build host never needs the

# production data volume and never reaches this section with --build-only.

docker volume inspect "$volume_name" >/dev/null 2>&1; or die "Required volume does not exist: $volume_name"

# Fail closed if an existing container named vaultwarden points /data somewhere

# other than the expected named volume.

if docker container inspect vaultwarden >/dev/null 2>&1

    set -l current_data_type (docker container inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}{{end}}{{end}}' vaultwarden 2>/dev/null)

    set -l current_data_name (docker container inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}' vaultwarden 2>/dev/null)

    test "$current_data_type" = "volume"; or die "Existing vaultwarden container does not mount /data from a Docker volume"

    test "$current_data_name" = "$volume_name"; or die "Existing vaultwarden container uses '$current_data_name' for /data, expected '$volume_name'"

end

# Re-verify immediately before using the image for any maintenance/deployment.

echo "[verify] Re-verifying deployment image..."

fish "$image_cache" verify "$vw_version"; or die "Deployment image verification failed"

echo "[data] Checking existing volume ownership..."

set -l ownership_probe (docker run --rm --pull=never --user 0:0 --entrypoint /bin/sh \

    -v "$volume_name:/data" \

    "$image_name" \

    -ec 'find /data -xdev \( ! -user 2000 -o ! -group 2000 \) -print -quit')

set -l ownership_status $status

test $ownership_status -eq 0; or die "Could not inspect volume ownership (maintenance container exit $ownership_status)"

if test -n "$ownership_probe"

    echo "      Ownership mismatch detected at: $ownership_probe"

    echo "      Repair to 2000:2000 is required."

    if docker container inspect vaultwarden >/dev/null 2>&1

        set -l is_running (docker container inspect --format '{{.State.Running}}' vaultwarden 2>/dev/null)

        if test "$is_running" = "true"

            echo "      Stopping Vaultwarden before changing volume ownership..."

            docker stop --time 30 vaultwarden >/dev/null; or die "Could not stop Vaultwarden"

        end

    end

    # Root is used only by this short maintenance shell to repair the volume.

    # It never executes the Vaultwarden binary.

    docker run --rm --pull=never --user 0:0 --entrypoint /bin/sh \

        -v "$volume_name:/data" \

        "$image_name" \

        -ec 'chown -R 2000:2000 /data'; or die "Could not repair volume ownership"

    set -l ownership_recheck (docker run --rm --pull=never --user 0:0 --entrypoint /bin/sh \

        -v "$volume_name:/data" \

        "$image_name" \

        -ec 'find /data -xdev \( ! -user 2000 -o ! -group 2000 \) -print -quit')

    set -l recheck_status $status

    test $recheck_status -eq 0; or die "Could not recheck volume ownership"

    test -z "$ownership_recheck"; or die "Volume ownership still mismatches at: $ownership_recheck"

else

    echo "      Volume already matches 2000:2000; no chown needed."

end

echo "[deploy] Starting Vaultwarden with the verified local image..."

docker compose -f "$compose_file" up -d --no-build --pull never --remove-orphans vaultwarden; or die "Compose deployment failed"

echo "[runtime] Verifying running container security invariants..."

set -l runtime_user (docker container inspect --format '{{.Config.User}}' vaultwarden 2>/dev/null)

or die "Could not inspect running container"

test "$runtime_user" = "2000:2000"; or die "Running container user is '$runtime_user', expected 2000:2000"

docker exec vaultwarden /bin/sh -ec 'test "$(id -u):$(id -g)" = "2000:2000"'; or die "Vaultwarden process namespace is not running as 2000:2000"

set -l readonly_root (docker container inspect --format '{{.HostConfig.ReadonlyRootfs}}' vaultwarden)

test "$readonly_root" = "true"; or die "Running container root filesystem is not read-only"

set -l security_opts (docker container inspect --format '{{json .HostConfig.SecurityOpt}}' vaultwarden)

string match -q '*no-new-privileges:true*' -- "$security_opts"; or die "no-new-privileges is not active"

set -l cap_drop (docker container inspect --format '{{json .HostConfig.CapDrop}}' vaultwarden)

string match -q '*ALL*' -- "$cap_drop"; or die "ALL capabilities are not dropped"

set -l mounted_volume (docker container inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}' vaultwarden)

test "$mounted_volume" = "$volume_name"; or die "Running container is not using $volume_name for /data"

echo "[health] Waiting for Vaultwarden health check to succeed..."

set -l healthy 0

set -l attempt 1

while test $attempt -le 30

    if docker exec vaultwarden /healthcheck.sh >/dev/null 2>&1

        set healthy 1

        break

    end

    sleep 2

    set attempt (math $attempt + 1)

end

test $healthy -eq 1; or die "Vaultwarden did not pass /healthcheck.sh within 60 seconds"

echo "Deployment complete."

echo "      Release: $vw_version"

echo "      Commit:  $commit"

echo "      Image:   $image_name"

docker compose -f "$compose_file" ps vaultwarden

4. image-cache.fish

This helper owns image verification and portable archive import/export. Note the deliberately simple checksum handling: sha256sum writes its own canonical sidecar, and the script immediately verifies that sidecar before reporting export success.


#!/usr/bin/env fish

function die

    echo "ERROR: $argv" >&2

    exit 1

end

function require_commands

    for command_name in $argv

        type -q $command_name; or begin

            echo "ERROR: Required command not found: $command_name" >&2

            return 1

        end

    end

end

function usage

    echo "Usage: image-cache.fish <ensure|verify|import|export|path> VERSION"

    echo

    echo "  ensure VERSION  Use an existing image, otherwise import its verified dist archive."

    echo "                  Returns status 10 when neither exists."

    echo "  verify VERSION  Verify the local image security/provenance invariants."

    echo "  import VERSION  Verify checksum and load the matching dist archive."

    echo "  export VERSION  Verify and export the local image as a zstd Docker archive."

    echo "  path VERSION    Print the expected archive path."

end

set -l script_file (status --current-filename)

set -l control_dir (cd (dirname "$script_file"); and pwd)

cd "$control_dir"; or die "Could not enter $control_dir"

set -g image_cache_dist_dir "$control_dir/dist"

set -g image_cache_expected_platform "linux/amd64"

set -g image_cache_expected_user "2000:2000"

function archive_path --argument-names vw_version

    echo "$image_cache_dist_dir/vaultwarden-secure-$vw_version-linux-amd64.tar.zst"

end

function checksum_path --argument-names vw_version

    echo (archive_path "$vw_version").sha256

end

function verify_image --argument-names vw_version

    set -l image_name "vaultwarden-secure:$vw_version"

    docker image inspect "$image_name" >/dev/null 2>&1; or begin

        echo "ERROR: Image is not present: $image_name" >&2

        return 1

    end

    set -l configured_user (docker image inspect --format '{{.Config.User}}' "$image_name" 2>/dev/null)

    test "$configured_user" = "$image_cache_expected_user"; or begin

        echo "ERROR: $image_name Config.User is '$configured_user', expected $image_cache_expected_user" >&2

        return 1

    end

    set -l image_os (docker image inspect --format '{{.Os}}' "$image_name" 2>/dev/null)

    set -l image_arch (docker image inspect --format '{{.Architecture}}' "$image_name" 2>/dev/null)

    test "$image_os/$image_arch" = "$image_cache_expected_platform"; or begin

        echo "ERROR: $image_name platform is '$image_os/$image_arch', expected $image_cache_expected_platform" >&2

        return 1

    end

    set -l label_version (docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "$image_name" 2>/dev/null)

    test "$label_version" = "$vw_version"; or begin

        echo "ERROR: $image_name OCI version label is '$label_version', expected '$vw_version'" >&2

        return 1

    end

    set -l revision (docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image_name" 2>/dev/null)

    string match -rq '^[0-9a-f]{40}$' -- "$revision"; or begin

        echo "ERROR: $image_name has invalid OCI revision label '$revision'" >&2

        return 1

    end

    docker run --rm --pull=never --entrypoint /bin/sh "$image_name" -ec 'test "$(id -u):$(id -g)" = "2000:2000"'; or begin

        echo "ERROR: $image_name runtime identity is not 2000:2000" >&2

        return 1

    end

    echo "      Verified image: $image_name ($image_cache_expected_platform, user $image_cache_expected_user, revision $revision)"

end

function import_image --argument-names vw_version

    require_commands sha256sum basename; or return 1

    set -l archive (archive_path "$vw_version")

    set -l checksum (checksum_path "$vw_version")

    set -l image_name "vaultwarden-secure:$vw_version"

    test -f "$archive"; or begin

        echo "ERROR: Image archive is missing: $archive" >&2

        return 1

    end

    test -f "$checksum"; or begin

        echo "ERROR: Image archive checksum is missing: $checksum" >&2

        return 1

    end

    echo "      Verifying archive SHA-256..."

    set -l archive_base (basename "$archive")

    set -l checksum_base (basename "$checksum")

    pushd "$image_cache_dist_dir" >/dev/null; or return 1

    sha256sum -c "$checksum_base"

    set -l checksum_status $status

    popd >/dev/null

    test $checksum_status -eq 0; or begin

        echo "ERROR: Archive checksum verification failed: $archive_base" >&2

        return 1

    end

    echo "      Loading $archive_base into Docker..."

    docker image load --input "$archive"; or begin

        echo "ERROR: docker image load failed for $archive" >&2

        return 1

    end

    docker image inspect "$image_name" >/dev/null 2>&1; or begin

        echo "ERROR: Archive loaded, but expected tag is absent: $image_name" >&2

        return 1

    end

    verify_image "$vw_version"

end

function export_image --argument-names vw_version

    require_commands sha256sum basename mkdir mv rm head zstd; or return 1

    set -l image_name "vaultwarden-secure:$vw_version"

    set -l archive (archive_path "$vw_version")

    set -l checksum (checksum_path "$vw_version")

    set -l tmp_archive "$archive.tmp.$fish_pid"

    set -l tmp_checksum "$checksum.tmp.$fish_pid"

    verify_image "$vw_version"; or return 1

    mkdir -p "$image_cache_dist_dir"; or return 1

    rm -f "$tmp_archive" "$tmp_checksum"

    echo "      Exporting $image_name -> "(basename "$archive")

    docker image save "$image_name" | zstd -T0 -10 -q -o "$tmp_archive"

    set -l pipe_status $pipestatus

    if test (count $pipe_status) -ne 2; or test $pipe_status[1] -ne 0; or test $pipe_status[2] -ne 0

        rm -f "$tmp_archive" "$tmp_checksum"

        echo "ERROR: docker image save / zstd export pipeline failed" >&2

        return 1

    end

    mv "$tmp_archive" "$archive"; or return 1

    set -l archive_base (basename "$archive")

    set -l checksum_base (basename "$checksum")

    # Generate the sidecar directly with sha256sum from inside dist/.

    # Avoid parsing/reformatting the digest so the sidecar is exactly what

    # `sha256sum -c` expects on the receiving host.

    pushd "$image_cache_dist_dir" >/dev/null; or return 1

    sha256sum "$archive_base" > "$tmp_checksum"

    set -l checksum_write_status $status

    popd >/dev/null

    test $checksum_write_status -eq 0; or begin

        rm -f "$tmp_checksum"

        echo "ERROR: Could not calculate archive SHA-256" >&2

        return 1

    end

    mv "$tmp_checksum" "$checksum"; or return 1

    # Fail closed if the just-generated sidecar does not verify the finished

    # compressed archive. This catches stale/mismatched sidecars immediately.

    pushd "$image_cache_dist_dir" >/dev/null; or return 1

    sha256sum -c "$checksum_base" >/dev/null

    set -l checksum_verify_status $status

    set -l digest (sha256sum "$archive_base" | string split ' ' | head -n 1)

    popd >/dev/null

    test $checksum_verify_status -eq 0; or begin

        echo "ERROR: Generated archive checksum failed immediate verification" >&2

        return 1

    end

    echo "      Archive: $archive"

    echo "      SHA-256: $digest"

end

if test (count $argv) -ne 2

    usage >&2

    exit 2

end

set -l action "$argv[1]"

set -l vw_version "$argv[2]"

string match -rq '^[0-9]+\.[0-9]+\.[0-9]+$' -- "$vw_version"; or die "Version must look like 1.37.1"

set -l image_name "vaultwarden-secure:$vw_version"

if test "$action" = path

    archive_path "$vw_version"

    exit 0

end

require_commands docker; or exit 1

docker info >/dev/null 2>&1; or die "Docker daemon is unavailable"

switch "$action"

    case path

        archive_path "$vw_version"

    case verify

        verify_image "$vw_version"; or exit 1

    case import

        import_image "$vw_version"; or exit 1

    case export

        export_image "$vw_version"; or exit 1

    case ensure

        if docker image inspect "$image_name" >/dev/null 2>&1

            echo "      Using already-loaded image: $image_name"

            verify_image "$vw_version"; or exit 1

            exit 0

        end

        set -l archive (archive_path "$vw_version")

        if test -f "$archive"

            echo "      Image is absent; matching archive found."

            import_image "$vw_version"; or exit 1

            exit 0

        end

        echo "      Neither local image nor archive exists for $image_name"

        exit 10

    case '*'

        usage >&2

        die "Unknown action: $action"

end

5. lint.fish

The lint gate checks both syntax and deployment invariants before a build. It validates the prepared Git checkout, confirms that only the expected Dockerfile changed, checks the Compose model, runs BuildKit's build checks, and runs a pinned Hadolint container with networking disabled.


#!/usr/bin/env fish

function die

    echo "ERROR: $argv" >&2

    exit 1

end

set -l script_file (status --current-filename)

set -l control_dir (cd (dirname "$script_file"); and pwd)

cd "$control_dir"; or die "Could not enter $control_dir"

set -l compose_file "$control_dir/compose.yaml"

set -l dockerfile "$control_dir/src/docker/Dockerfile.alpine"

set -l patch_file "$control_dir/security.patch"

set -l env_file "$control_dir/.env"

set -l hadolint_config "$control_dir/.hadolint.yaml"

# Current Hadolint release when this package was created, pinned to GHCR's

# published multi-arch manifest digest.

set -l hadolint_image "ghcr.io/hadolint/hadolint:v2.15.1-alpine@sha256:a1d49ae1a4e83c1dbad26b8c1ad7588c8bd1e04f4866b34ad3cac50335198552"

for command_name in docker git grep head fish

    type -q $command_name; or die "Required command not found: $command_name"

end

docker compose version >/dev/null 2>&1; or die "Docker Compose v2 is required"

docker info >/dev/null 2>&1; or die "Docker daemon is unavailable"

test -f "$compose_file"; or die "Missing compose.yaml"

test -f "$dockerfile"; or die "Missing prepared Dockerfile: $dockerfile. Run ./deploy.fish first."

test -f "$patch_file"; or die "Missing security.patch"

test -f "$env_file"; or die "Missing .env"

test -f "$hadolint_config"; or die "Missing .hadolint.yaml"

echo "[1/6] Checking Fish control scripts..."

fish -n "$control_dir/deploy.fish"; or die "deploy.fish syntax check failed"

fish -n "$control_dir/image-cache.fish"; or die "image-cache.fish syntax check failed"

fish -n "$control_dir/lint.fish"; or die "lint.fish syntax check failed"

echo "[2/6] Verifying prepared source identity and patch state..."

set -l env_version (grep -E '^VW_VERSION=' "$env_file" | head -n 1 | string replace 'VW_VERSION=' '')

set -l env_commit (grep -E '^VW_GIT_COMMIT=' "$env_file" | head -n 1 | string replace 'VW_GIT_COMMIT=' '')

string match -rq '^[0-9]+\.[0-9]+\.[0-9]+$' -- "$env_version"; or die "Invalid VW_VERSION in .env"

string match -rq '^[0-9a-f]{40}$' -- "$env_commit"; or die "Invalid VW_GIT_COMMIT in .env"

set -l head_commit (git -C "$control_dir/src" rev-parse HEAD 2>/dev/null)

or die "src/ is not a readable Git checkout"

test "$head_commit" = "$env_commit"; or die "Prepared source HEAD $head_commit does not match .env commit $env_commit"

set -l tag_commit (git -C "$control_dir/src" rev-parse "refs/tags/$env_version^{commit}" 2>/dev/null)

or die "Prepared source does not contain tag $env_version"

test "$tag_commit" = "$env_commit"; or die "Tag $env_version resolves to $tag_commit, not $env_commit"

set -l changed_files (git -C "$control_dir/src" diff --name-only)

test "$changed_files" = "docker/Dockerfile.alpine"; or die "Unexpected modified source files: $changed_files"

set -l untracked_files (git -C "$control_dir/src" ls-files --others --exclude-standard)

test (count $untracked_files) -eq 0; or die "Unexpected untracked source files: $untracked_files"

git -C "$control_dir/src" diff --check; or die "Prepared source has whitespace/errors in its diff"

git -C "$control_dir/src" apply --reverse --check "$patch_file"; or die "Prepared Dockerfile does not contain the expected reversible hardening patch"

echo "[3/6] Validating resolved Compose model..."

docker compose -f "$compose_file" config --quiet; or die "docker compose config validation failed"

echo "[4/6] Checking required security invariants..."

grep -Fq 'platform: linux/amd64' "$compose_file"; or die "Compose must pin linux/amd64"

grep -Fq 'pull_policy: never' "$compose_file"; or die "Compose must never pull the runtime image"

grep -Fq 'user: "2000:2000"' "$compose_file"; or die "Compose must enforce user 2000:2000"

grep -Fq 'read_only: true' "$compose_file"; or die "Compose must enforce a read-only root filesystem"

grep -Fq '127.0.0.1:3001:8080' "$compose_file"; or die "Compose port binding must remain loopback-only"

grep -Fq 'no-new-privileges:true' "$compose_file"; or die "Compose must enable no-new-privileges"

grep -Fq 'cap_drop:' "$compose_file"; or die "Compose must drop capabilities"

grep -Fq -- '- ALL' "$compose_file"; or die "Compose must drop ALL capabilities"

grep -Fq 'external: true' "$compose_file"; or die "Vaultwarden data volume must be external"

grep -Fq 'name: vaultwarden_vaultwarden_data' "$compose_file"; or die "Compose must use the existing named data volume"

grep -Fq 'ROCKET_PORT=8080' "$dockerfile"; or die "Dockerfile must use port 8080"

grep -Fq 'addgroup -S vaultwarden -g 2000' "$dockerfile"; or die "Dockerfile must create GID 2000"

grep -Fq 'adduser -S vaultwarden -G vaultwarden -u 2000' "$dockerfile"; or die "Dockerfile must create UID 2000"

grep -Fq 'chown -R 2000:2000 /data' "$dockerfile"; or die "Dockerfile must own /data as 2000:2000"

grep -Eq '^EXPOSE[[:space:]]+8080$' "$dockerfile"; or die "Dockerfile must expose 8080"

grep -Eq '^USER[[:space:]]+2000:2000$' "$dockerfile"; or die "Dockerfile final runtime USER must be 2000:2000"

grep -Fq 'org.opencontainers.image.revision=' "$dockerfile"; or die "Dockerfile must record source revision"

echo "[5/6] Running Docker BuildKit checks..."

docker compose -f "$compose_file" build --check vaultwarden; or die "Docker BuildKit checks failed"

echo "[6/6] Running Hadolint v2.15.1..."

docker run --rm --network none \

    -v "$hadolint_config:/config/hadolint.yaml:ro" \

    -v "$dockerfile:/work/Dockerfile:ro" \

    "$hadolint_image" \

    /bin/hadolint --config /config/hadolint.yaml /work/Dockerfile; or die "Hadolint failed"

echo "Lint checks passed."

6. .hadolint.yaml

Vaultwarden's upstream Dockerfile uses a few Bash/build patterns that Hadolint objects to. The ignores here are narrow and documented so the lint gate still catches meaningful mistakes in the local hardening.


ignored:

  # To prevent issues and make clear some images only work on linux/amd64, we ignore this

    - DL3029

  # disable explicit version for apt install

    - DL3008

  # disable explicit version for apk install

    - DL3018

  # Ignore shellcheck info message

    - SC1091

  # Upstream Vaultwarden Dockerfile uses `source` in the rust-musl build stage.

    - SC3046

  # Upstream rust-musl build stage uses bash-style [[ ... == ... ]].

    - SC3014

  # Upstream explicitly sets TARGETPLATFORM due to a documented Podman/Buildah workaround.

    - DL3065

trustedRegistries:

    - docker.io

    - ghcr.io

    - quay.io

7. .gitignore

The source checkout and generated image artifacts do not belong in the control-plane repository.


src/

dist/

.env.tmp

*.resolved.yaml

8. .env

This file contains provenance, not secrets. deploy.fish rewrites it after selecting or verifying an image. For the 1.37.1 example it looks like this:


VW_VERSION=1.37.1

VW_GIT_COMMIT=2629bcbe1380c894e3a7f52cafcac3988edb8fbb

Do not blindly copy the commit value for another version. The script resolves the selected upstream tag and writes the corresponding commit itself.

9. Fish completions

The script uses Fish, so the operator scripts have native completion. The completion code never contacts the network while tab-completing; it learns version candidates from the local .env, matching dist/*.tar.zst files, and local Git tags if src/ exists.

install-completions.fish


#!/usr/bin/env fish

function die

    echo "ERROR: $argv" >&2

    exit 1

end

function usage

    echo "Usage: install-completions.fish [--user | --system] [--remove]"

    echo

    echo "  --user      Install for the current user (default)."

    echo "  --system    Install for all users under /etc/fish."

    echo "  --remove    Remove the selected completion files and loader."

    echo "  -h, --help  Show this help."

end

argparse 'h/help' 'user' 'system' 'remove' -- $argv

or begin

    usage >&2

    exit 2

end

if set -q _flag_help

    usage

    exit 0

end

test (count $argv) -eq 0; or die "Unexpected argument: $argv"

set -q _flag_user; and set -q _flag_system; and die "Choose only one of --user or --system"

set -l script_file (status --current-filename)

set -l control_dir (cd (dirname "$script_file"); and pwd)

set -l source_dir "$control_dir/completions"

test -d "$source_dir"; or die "Missing completions directory: $source_dir"

set -l config_root

if set -q _flag_system

    set config_root /etc/fish

else if set -q XDG_CONFIG_HOME; and test -n "$XDG_CONFIG_HOME"

    set config_root "$XDG_CONFIG_HOME/fish"

else

    set config_root "$HOME/.config/fish"

end

set -l completion_dir "$config_root/completions"

set -l conf_dir "$config_root/conf.d"

set -l names deploy.fish.fish image-cache.fish.fish

set -l loader "$conf_dir/vaultwarden-control-completions.fish"

if set -q _flag_remove

    for name in $names

        rm -f "$completion_dir/$name"; or die "Could not remove $completion_dir/$name"

    end

    rm -f "$loader"; or die "Could not remove $loader"

    echo "Removed Vaultwarden Fish completions from $config_root"

    exit 0

end

mkdir -p "$completion_dir" "$conf_dir"; or die "Could not create Fish configuration directories under $config_root"

for name in $names

    test -f "$source_dir/$name"; or die "Missing completion source: $source_dir/$name"

    cp "$source_dir/$name" "$completion_dir/$name"; or die "Could not install $name"

    chmod 0644 "$completion_dir/$name"; or die "Could not set permissions on $completion_dir/$name"

end

# Fish normally autoloads command completions by command name. The small eager

# loader also makes completion work when these scripts are invoked by a path

# such as ./deploy.fish while /opt/vaultwarden-control is not itself in PATH.

printf '%s\n' \

    '# Vaultwarden control completion loader; installed by install-completions.fish.' \

    'for completion_file in deploy.fish.fish image-cache.fish.fish' \

    '    set completion_path "'(string escape -- "$completion_dir")'/$completion_file"' \

    '    test -f "$completion_path"; and source "$completion_path"' \

    'end' > "$loader"; or die "Could not write $loader"

chmod 0644 "$loader"; or die "Could not set permissions on $loader"

echo "Installed Vaultwarden Fish completions under $config_root"

echo "Open a new Fish shell, or source $loader in the current shell."

completions/deploy.fish.fish


# Fish completions for Vaultwarden control deploy.fish.

function __vw_deploy_control_dir

    set -l tokens (commandline -opc)

    test (count $tokens) -ge 1; or return 1

    set -l invoked $tokens[1]

    if string match -q '*/*' -- "$invoked"

        set -l parent (dirname "$invoked")

        set -l resolved (cd "$parent" 2>/dev/null; and pwd)

        if test -n "$resolved"

            echo "$resolved"

            return 0

        end

    else

        set -l resolved (type -p "$invoked" 2>/dev/null)

        if test -n "$resolved"

            dirname "$resolved"

            return 0

        end

    end

    pwd

end

function __vw_deploy_versions

    set -l control_dir (__vw_deploy_control_dir)

    test -n "$control_dir"; or return 0

    set -l versions 1.37.1

    if test -f "$control_dir/.env"

        set -l env_version (string match -rg '^VW_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$' < "$control_dir/.env")

        if test -n "$env_version"; and not contains -- "$env_version" $versions

            set -a versions "$env_version"

        end

    end

    if test -d "$control_dir/dist"

        for archive in "$control_dir"/dist/vaultwarden-secure-*-linux-amd64.tar.zst

            test -e "$archive"; or continue

            set -l base (basename "$archive")

            set -l archive_version (string match -rg '^vaultwarden-secure-([0-9]+\.[0-9]+\.[0-9]+)-linux-amd64\.tar\.zst$' -- "$base")

            if test -n "$archive_version"; and not contains -- "$archive_version" $versions

                set -a versions "$archive_version"

            end

        end

    end

    if test -d "$control_dir/src/.git"; and type -q git

        for tag in (git -C "$control_dir/src" tag --list 2>/dev/null)

            if string match -rq '^[0-9]+\.[0-9]+\.[0-9]+$' -- "$tag"; and not contains -- "$tag" $versions

                set -a versions "$tag"

            end

        end

    end

    for candidate in $versions

        printf '%s\tVaultwarden version\n' "$candidate"

    end

end

function __vw_deploy_version_allowed

    set -l tokens (commandline -opc)

    set -l positional 0

    for token in $tokens[2..-1]

        if string match -q -- '-*' "$token"

            continue

        end

        set positional (math $positional + 1)

    end

    test $positional -eq 0

end

complete -c deploy.fish -f

complete -c deploy.fish -s h -l help -d 'Show help'

complete -c deploy.fish -s b -l build -d 'Force source rebuild even if image/archive exists'

complete -c deploy.fish -l build-only -d 'Build, verify and export without deployment'

complete -c deploy.fish -l no-build -d 'Never build; require local image or verified archive'

complete -c deploy.fish -l no-export -d 'Do not export image archive after source build'

complete -c deploy.fish -n '__vw_deploy_version_allowed' -a '(__vw_deploy_versions)'

completions/image-cache.fish.fish


# Fish completions for Vaultwarden control image-cache.fish.

function __vw_cache_control_dir

    set -l tokens (commandline -opc)

    test (count $tokens) -ge 1; or return 1

    set -l invoked $tokens[1]

    if string match -q '*/*' -- "$invoked"

        set -l parent (dirname "$invoked")

        set -l resolved (cd "$parent" 2>/dev/null; and pwd)

        if test -n "$resolved"

            echo "$resolved"

            return 0

        end

    else

        set -l resolved (type -p "$invoked" 2>/dev/null)

        if test -n "$resolved"

            dirname "$resolved"

            return 0

        end

    end

    pwd

end

function __vw_cache_versions

    set -l control_dir (__vw_cache_control_dir)

    test -n "$control_dir"; or return 0

    set -l versions 1.37.1

    if test -f "$control_dir/.env"

        set -l env_version (string match -rg '^VW_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$' < "$control_dir/.env")

        if test -n "$env_version"; and not contains -- "$env_version" $versions

            set -a versions "$env_version"

        end

    end

    if test -d "$control_dir/dist"

        for archive in "$control_dir"/dist/vaultwarden-secure-*-linux-amd64.tar.zst

            test -e "$archive"; or continue

            set -l base (basename "$archive")

            set -l archive_version (string match -rg '^vaultwarden-secure-([0-9]+\.[0-9]+\.[0-9]+)-linux-amd64\.tar\.zst$' -- "$base")

            if test -n "$archive_version"; and not contains -- "$archive_version" $versions

                set -a versions "$archive_version"

            end

        end

    end

    if test -d "$control_dir/src/.git"; and type -q git

        for tag in (git -C "$control_dir/src" tag --list 2>/dev/null)

            if string match -rq '^[0-9]+\.[0-9]+\.[0-9]+$' -- "$tag"; and not contains -- "$tag" $versions

                set -a versions "$tag"

            end

        end

    end

    for candidate in $versions

        printf '%s\tVaultwarden version\n' "$candidate"

    end

end

function __vw_cache_needs_action

    set -l tokens (commandline -opc)

    test (count $tokens) -le 1

end

function __vw_cache_needs_version

    set -l tokens (commandline -opc)

    test (count $tokens) -eq 2

end

complete -c image-cache.fish -f

complete -c image-cache.fish -n '__vw_cache_needs_action' -a 'verify' -d 'Verify image platform, labels and runtime UID/GID'

complete -c image-cache.fish -n '__vw_cache_needs_action' -a 'export' -d 'Export image to dist/*.tar.zst with SHA-256 sidecar'

complete -c image-cache.fish -n '__vw_cache_needs_action' -a 'import' -d 'Verify checksum, load archive and verify image'

complete -c image-cache.fish -n '__vw_cache_needs_action' -a 'ensure' -d 'Use local image or import matching archive'

complete -c image-cache.fish -n '__vw_cache_needs_action' -a 'path' -d 'Print expected archive path'

complete -c image-cache.fish -n '__vw_cache_needs_version' -a '(__vw_cache_versions)'

Make the operator scripts executable:


chmod +x deploy.fish image-cache.fish lint.fish install-completions.fish

Install the completions for the current user:


./install-completions.fish

exec fish

or system-wide:


sudo ./install-completions.fish --system

exec fish

Building on the fast host

For the initial SQLite-only build of 1.37.1, This build explicitly forces a build so an older image with the same local tag cannot be reused:


cd /opt/vaultwarden-control

./deploy.fish --build-only 1.37.1

The script fetches the exact 1.37.1 tag, checks out its commit detached, cleans the source tree, verifies the upstream Alpine runtime is at least 3.24, applies the patch, lints, builds, verifies, and exports.

The outputs are:


dist/vaultwarden-secure-1.37.1-linux-amd64.tar.zst

dist/vaultwarden-secure-1.37.1-linux-amd64.tar.zst.sha256

Check the artifact independently:


cd dist

sha256sum -c vaultwarden-secure-1.37.1-linux-amd64.tar.zst.sha256

It should report OK.

The compressed archive is intentionally versioned by filename. There is no need to provide its full path to the deployment script; image-cache.fish derives the expected path from the Vaultwarden version.

Scanning the image

The system scans the built image with Trivy before shipping it:


trivy image --ignore-unfixed vaultwarden-secure:1.37.1

--ignore-unfixed belongs after the image subcommand; it is not a global Trivy flag.

At the time the system rebuilt this image, Trivy reported zero known vulnerabilities. Again, that is a point-in-time result. Keep the scanner database current and repeat the scan for each rebuild.

If you want a machine-readable artifact as well:


trivy image     --ignore-unfixed     --format json     --output trivy-vaultwarden-1.37.1.json     vaultwarden-secure:1.37.1

Moving it to the slow production host

Copy exactly two generated files:


vaultwarden-secure-1.37.1-linux-amd64.tar.zst

vaultwarden-secure-1.37.1-linux-amd64.tar.zst.sha256

Place them under the production control directory's dist/:


/opt/vaultwarden-control/dist/

You can explicitly test the import first:


cd /opt/vaultwarden-control

./image-cache.fish import 1.37.1

./image-cache.fish verify 1.37.1

or simply let the deployment preflight do it:


./deploy.fish --no-build 1.37.1

Use --no-build on a deliberately slow production machine. If the image/archive is missing or invalid, deployment fails instead of unexpectedly starting a long source compile.

Existing data volume

This setup expects an external Docker volume named:


vaultwarden_vaultwarden_data

For a new installation you can create it explicitly:


docker volume create vaultwarden_vaultwarden_data

For an existing production installation, do not create a replacement volume if your real data already lives elsewhere. Determine the actual volume name and adjust both compose.yaml and the script constant deliberately. The whole point of the external-volume check is to prevent an upgrade from accidentally presenting an empty vault because Compose silently created a new volume.

Before an upgrade the system takes a host/container snapshot appropriate to the local environment. This control plane intentionally does not grow its own backup and rollback framework; we already have a better rollback boundary outside Docker.

Normal upgrades

Once the workflow is established, a later release is intentionally dull.

On the fast builder, for a hypothetical 1.38.0 release:


./deploy.fish --build-only 1.38.0

The script refuses to proceed if 1.38.0 is not a real upstream tag.

Copy the two resulting 1.38.0 files to production, then:


./deploy.fish --no-build 1.38.0

That is the whole operational goal: make a security update small enough that there is no incentive to keep postponing it.

If we want deliberately want to rebuild the same Vaultwarden release against refreshed base layers and packages, use:


./deploy.fish --build 1.37.1

or, on a build-only host:


./deploy.fish --build-only 1.37.1

Because the build runs with --pull, it refreshes referenced build/base images while keeping the Vaultwarden source pinned to the exact release tag.

Why not just use a registry?

A private registry would also solve image transfer, but for one small self-hosted service it adds another long-lived service, authentication surface, certificate lifecycle, and set of credentials. A compressed docker save artifact plus SHA-256 sidecar is sufficient here and is easy to archive or move over an existing secure channel.

Likewise, the decision was made not put a Docker-socket-enabled helper container into Compose just to call docker load. The host-side preflight already has Docker access and can do the job without handing /var/run/docker.sock to another container.

Why SQLite-only?

Vaultwarden's upstream Dockerfile supports multiple database backends because the project has to support many deployments. This implementation does not. This implementation usesSQLite, so compiling MySQL and PostgreSQL support buys me nothing.

The upstream build argument makes the reduction straightforward:


build:

  args:

    DB: "sqlite,enable_mimalloc"

This is a good kind of hardening: remove unused functionality rather than add another security mechanism around it. It also cuts part of the Rust dependency/build graph, so source builds should be somewhat lighter.

There is an obvious tradeoff: this resulting binary cannot later be pointed at MySQL or PostgreSQL.

Things deliberately not added

It is easy for deployment tooling to become its own software project. This implmententation avoided several tempting additions:

  • no private image registry;
  • no Docker socket inside Compose;
  • no automatic Docker-level rollback framework;
  • no generalized database-backend matrix;
  • no parsing or reconstruction of the checksum sidecar when sha256sum already produces exactly the format it can verify;
  • no network lookups during shell completion;
  • no silent patch fuzzing or fallback if upstream changes.

The rule is simple: add complexity only when it buys a concrete correctness, security, portability, validation, or maintenance benefit.

Final result

The important improvement is not any one hardening flag. It is that upgrades stopped being an ordeal.

The setup is now repeatable:


build exact upstream tag

        -> harden

        -> SQLite-only compile

        -> verify

        -> scan

        -> export + SHA-256

        -> transfer

        -> verify + import

        -> deploy without rebuilding

That gives us a current Vaultwarden image with less unused code, a tightly constrained runtime, auditable provenance, and a production update path that takes minutes of operator attention instead of turning into a separate project.

References