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
- It ran as local root in the container
- It didn't have a durable container
- it didn't switch to a non-privileged user within the container
- It didn't have a read-only filesystem
- 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:
- Freeze your bitwarden client they like to update without telling me
- Keep your vaultwarden server well maintained
- 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:
- Use an already-loaded image only if it passes verification.
- Otherwise import a matching verified archive from
dist/. - Otherwise build from the exact upstream tag, unless
--no-buildwas 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
sha256sumalready 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
- Vaultwarden upstream: https://github.com/dani-garcia/vaultwarden
- Vaultwarden Alpine Dockerfile (including the
DBbuild argument): https://github.com/dani-garcia/vaultwarden/blob/main/docker/Dockerfile.alpine - Vaultwarden Docker build documentation (including SQLite-only examples): https://github.com/dani-garcia/vaultwarden/blob/main/docker/README.md
- Vaultwarden 1.37.1 release: https://github.com/dani-garcia/vaultwarden/releases/tag/1.37.1
- Docker Compose build specification: https://docs.docker.com/reference/compose-file/build/
- Docker Compose
pull_policy: https://docs.docker.com/reference/compose-file/services/#pull_policy - Trivy image command: https://trivy.dev/docs/latest/references/configuration/cli/trivy_image/
- Fish completion documentation: https://fishshell.com/docs/current/completions.html