feat(ops): deploy.sh — build, tag, replace, prune
One-shot deploy script that wraps the podman build + run dance: ./deploy.sh # build → replace container → prune (default) ./deploy.sh build # build + tag only ./deploy.sh run # restart from existing :latest, no rebuild ./deploy.sh prune # drop old timestamped images ./deploy.sh config # print resolved configuration Each build tags the image with both :latest AND a UTC timestamp (YYYYMMDD-HHMMSS), so rollback is a tag retag away. Prune keeps the N most recent timestamped images (KEEP_IMAGES, default 3); :latest is never touched. The matching regex is strict — only the exact YYYYMMDD-HHMMSS pattern — so a stray "dev" or hand-typed tag can't get caught. Settings come from an optional deploy.env (gitignored; example in deploy.env.example). Parser is allowlist-based: only recognised keys apply, malformed lines and command-substitution forms are ignored. Available overrides: IMAGE_NAME, CONTAINER_NAME, VOLUME_NAME, HOST_PORT, BIND_ADDR, KEEP_IMAGES, PUBLIC_BASE_URL, EXTRA_PODMAN_RUN_ARGS. HOST_PORT and KEEP_IMAGES are integer- validated before use. Uses podman run --replace per the global ops guidance (atomic, idempotent, no stop→rm→run race). BUILDAH_FORMAT=docker so the HEALTHCHECK directive in the Containerfile survives. shellcheck clean. README's Deployment section rewritten to lead with the script; manual podman snippet kept as fallback.
This commit is contained in:
parent
8ac1d8a0e6
commit
d29e1fd3d5
4 changed files with 273 additions and 11 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ data/
|
|||
# inspecting the live site. Never source of truth, never committed.
|
||||
.playwright-mcp/
|
||||
*.png
|
||||
deploy.env
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -103,10 +103,53 @@ still works.
|
|||
|
||||
## Deployment
|
||||
|
||||
### Container (podman)
|
||||
### One-shot deploy: `./deploy.sh`
|
||||
|
||||
The provided `Containerfile` builds a single image that serves API + frontend
|
||||
and persists the SQLite database in `/app/data` (one volume).
|
||||
The repository ships a small bash deploy script that wraps the usual
|
||||
`podman build` + `podman run --replace` dance, tags images with both
|
||||
`:latest` and a UTC timestamp, and prunes older timestamped images so
|
||||
local disk doesn't grow forever:
|
||||
|
||||
```bash
|
||||
./deploy.sh # build → replace container → prune (default)
|
||||
./deploy.sh build # build + tag only
|
||||
./deploy.sh run # restart from existing :latest, no rebuild
|
||||
./deploy.sh prune # drop old timestamped images
|
||||
./deploy.sh config # print resolved configuration
|
||||
```
|
||||
|
||||
Settings come from `deploy.env` (gitignored). Copy the example and edit:
|
||||
|
||||
```bash
|
||||
cp deploy.env.example deploy.env
|
||||
```
|
||||
|
||||
Available overrides:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---------------------------|-----------------------|------------------------------------------------------------------|
|
||||
| `IMAGE_NAME` | `vinterliste` | Local image-tag prefix. |
|
||||
| `CONTAINER_NAME` | `vinterliste` | `podman` container name. |
|
||||
| `VOLUME_NAME` | `vinterliste-data` | Named volume holding the SQLite file. |
|
||||
| `HOST_PORT` | `3000` | Host port mapped to the container's `:3000`. |
|
||||
| `BIND_ADDR` | `0.0.0.0` | Use `127.0.0.1` when fronting with a reverse proxy on the host. |
|
||||
| `KEEP_IMAGES` | `3` | Number of timestamped images to keep on prune. |
|
||||
| `PUBLIC_BASE_URL` | (auto) | Canonical URL injected into OpenGraph tags. |
|
||||
| `EXTRA_PODMAN_RUN_ARGS` | (empty) | Appended verbatim to `podman run`. Word-split, quote properly. |
|
||||
|
||||
The `.env` parser is allowlist-based — unknown keys log a warning and are
|
||||
ignored; lines that aren't `KEY=value` are skipped; quoted values are
|
||||
stripped of one layer of `'` or `"`. Command-substitution forms like
|
||||
`KEY=$(…)` are NOT evaluated, so a malicious `deploy.env` can't run code.
|
||||
|
||||
The container exposes `/api/health` for healthchecks and bakes the build
|
||||
date / git revision into both OCI labels and `/etc/build-info`. The
|
||||
script uses `podman run --replace` for atomicity (no `stop` → `rm` →
|
||||
`run` race).
|
||||
|
||||
### Manual `podman build` / `podman run`
|
||||
|
||||
If you don't want the script:
|
||||
|
||||
```bash
|
||||
BUILDAH_FORMAT=docker podman build \
|
||||
|
|
@ -114,10 +157,9 @@ BUILDAH_FORMAT=docker podman build \
|
|||
--build-arg GIT_REVISION="$(git describe --always --dirty 2>/dev/null || echo dev)" \
|
||||
-t vinterliste:latest .
|
||||
|
||||
# Create a named volume for the SQLite file
|
||||
podman volume create vinterliste-data
|
||||
podman volume create --ignore vinterliste-data
|
||||
|
||||
podman run --replace --name vinterliste \
|
||||
podman run --replace --name vinterliste -d \
|
||||
-p 3000:3000 \
|
||||
-v vinterliste-data:/app/data:Z \
|
||||
vinterliste:latest
|
||||
|
|
@ -125,11 +167,6 @@ podman run --replace --name vinterliste \
|
|||
# Visit http://localhost:3000
|
||||
```
|
||||
|
||||
The container exposes `/api/health` for healthchecks and bakes the build date /
|
||||
git revision into both OCI labels and `/etc/build-info`. Use `podman run
|
||||
--replace ...` for redeploys — it's atomic and avoids the "container exists"
|
||||
race.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|
|
|
|||
28
deploy.env.example
Normal file
28
deploy.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Vinterliste deploy.sh overrides. Copy to `deploy.env` (gitignored) and
|
||||
# uncomment / edit any setting. The script falls back to sensible defaults
|
||||
# for everything you don't override.
|
||||
|
||||
# Host port mapping. Container always listens on 3000 internally.
|
||||
# HOST_PORT=3000
|
||||
|
||||
# Bind only to loopback when fronting with a reverse proxy on the same host.
|
||||
# BIND_ADDR=127.0.0.1
|
||||
|
||||
# Image, container, and volume names. Useful if you run multiple instances
|
||||
# on the same machine (e.g. staging + prod).
|
||||
# IMAGE_NAME=vinterliste
|
||||
# CONTAINER_NAME=vinterliste
|
||||
# VOLUME_NAME=vinterliste-data
|
||||
|
||||
# How many old timestamped images to keep on the local registry. :latest is
|
||||
# never pruned.
|
||||
# KEEP_IMAGES=3
|
||||
|
||||
# Canonical public URL for OpenGraph tags. Falls back to the request host
|
||||
# when unset, which works for single-origin deploys.
|
||||
# PUBLIC_BASE_URL=https://vinterliste.example.org
|
||||
|
||||
# Anything extra you want appended to `podman run`. Word-split, so use
|
||||
# proper shell quoting. Example: pin the user namespace, attach an extra
|
||||
# label, change the restart policy, etc.
|
||||
# EXTRA_PODMAN_RUN_ARGS=--userns=keep-id --label deploy.env=prod
|
||||
196
deploy.sh
Executable file
196
deploy.sh
Executable file
|
|
@ -0,0 +1,196 @@
|
|||
#!/usr/bin/env bash
|
||||
# Vinterliste deploy script.
|
||||
#
|
||||
# Builds a podman image from the current working tree, tags it with both
|
||||
# :latest and a UTC timestamp, then atomically replaces the running
|
||||
# container with `podman run --replace`. Older timestamped images are
|
||||
# pruned afterwards.
|
||||
#
|
||||
# Configurable via deploy.env (gitignored). Every variable has a sensible
|
||||
# default — running ./deploy.sh on a fresh checkout works without setup.
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy.sh # build + restart
|
||||
# ./deploy.sh build # build image only, no container action
|
||||
# ./deploy.sh run # use the existing :latest image, no rebuild
|
||||
# ./deploy.sh prune # drop old timestamped images only
|
||||
#
|
||||
# Anything that would terminate a process verifies container identity by
|
||||
# name before acting (the global "process safety" rule). Image pruning
|
||||
# matches by image-name prefix + the :2YYYYMMDD- pattern only — never by
|
||||
# wildcards that could catch unrelated podman images.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve the project directory regardless of where the script was invoked
|
||||
# from. Symlinks are followed so the script can sit anywhere on $PATH.
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" && pwd)
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# --- Configuration ---------------------------------------------------------
|
||||
# Defaults first; deploy.env (if present) overrides any of them. We don't
|
||||
# `source` the .env file blindly — it goes through a key=value parser so a
|
||||
# malformed line can't run arbitrary code.
|
||||
|
||||
IMAGE_NAME="vinterliste"
|
||||
CONTAINER_NAME="vinterliste"
|
||||
VOLUME_NAME="vinterliste-data"
|
||||
HOST_PORT="3000"
|
||||
BIND_ADDR="0.0.0.0"
|
||||
KEEP_IMAGES="3"
|
||||
PUBLIC_BASE_URL=""
|
||||
EXTRA_PODMAN_RUN_ARGS=""
|
||||
|
||||
ENV_FILE="${ENV_FILE:-./deploy.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip blank lines and comments.
|
||||
[[ -z "${line// }" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
# Allowlist parse: VAR=VALUE, VAR must be uppercase + underscores.
|
||||
if [[ "$line" =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
# Strip optional surrounding quotes (single OR double).
|
||||
val="${val#\"}"; val="${val%\"}"
|
||||
val="${val#\'}"; val="${val%\'}"
|
||||
case "$key" in
|
||||
IMAGE_NAME|CONTAINER_NAME|VOLUME_NAME|HOST_PORT|BIND_ADDR|KEEP_IMAGES|PUBLIC_BASE_URL|EXTRA_PODMAN_RUN_ARGS)
|
||||
printf -v "$key" '%s' "$val"
|
||||
;;
|
||||
*)
|
||||
echo "deploy.env: ignoring unknown setting '$key'" >&2
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "deploy.env: ignoring malformed line: $line" >&2
|
||||
fi
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Validate that HOST_PORT and KEEP_IMAGES are integers — they go straight
|
||||
# into shell substitution further down.
|
||||
if ! [[ "$HOST_PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "HOST_PORT must be an integer, got: $HOST_PORT" >&2; exit 2
|
||||
fi
|
||||
if ! [[ "$KEEP_IMAGES" =~ ^[0-9]+$ ]]; then
|
||||
echo "KEEP_IMAGES must be an integer, got: $KEEP_IMAGES" >&2; exit 2
|
||||
fi
|
||||
|
||||
# Build-time metadata. Same shape as the README documents.
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
TIMESTAMP_TAG=$(date -u +%Y%m%d-%H%M%S)
|
||||
GIT_REVISION=$(git describe --always --dirty 2>/dev/null || echo dev)
|
||||
|
||||
# --- Helpers --------------------------------------------------------------
|
||||
log() { printf '[deploy] %s\n' "$*" >&2; }
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "deploy.sh needs '$1' on PATH" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Actions --------------------------------------------------------------
|
||||
|
||||
action_build() {
|
||||
require_cmd podman
|
||||
require_cmd git
|
||||
log "Building ${IMAGE_NAME}:${TIMESTAMP_TAG} (revision ${GIT_REVISION})"
|
||||
BUILDAH_FORMAT=docker podman build \
|
||||
--build-arg BUILD_DATE="$BUILD_DATE" \
|
||||
--build-arg GIT_REVISION="$GIT_REVISION" \
|
||||
-t "${IMAGE_NAME}:${TIMESTAMP_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
log "Built ${IMAGE_NAME}:${TIMESTAMP_TAG} and tagged :latest"
|
||||
}
|
||||
|
||||
action_run() {
|
||||
require_cmd podman
|
||||
# Confirm a :latest image exists before we try to replace the container.
|
||||
if ! podman image exists "${IMAGE_NAME}:latest"; then
|
||||
echo "No ${IMAGE_NAME}:latest image found. Run './deploy.sh build' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Volume creation is idempotent — `podman volume create` errors if it
|
||||
# already exists, but `--ignore` makes it a no-op.
|
||||
podman volume create --ignore "$VOLUME_NAME" >/dev/null
|
||||
|
||||
log "Replacing container ${CONTAINER_NAME} (image :latest, port ${BIND_ADDR}:${HOST_PORT} → 3000)"
|
||||
# shellcheck disable=SC2086 # EXTRA_PODMAN_RUN_ARGS is intentional word-split
|
||||
podman run --replace -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--restart=unless-stopped \
|
||||
-p "${BIND_ADDR}:${HOST_PORT}:3000" \
|
||||
-v "${VOLUME_NAME}:/app/data:Z" \
|
||||
${PUBLIC_BASE_URL:+-e PUBLIC_BASE_URL="$PUBLIC_BASE_URL"} \
|
||||
${EXTRA_PODMAN_RUN_ARGS} \
|
||||
"${IMAGE_NAME}:latest" >/dev/null
|
||||
log "Container started. Health: podman healthcheck run $CONTAINER_NAME"
|
||||
}
|
||||
|
||||
action_prune() {
|
||||
require_cmd podman
|
||||
# List timestamped tags only (the YYYYMMDD-HHMMSS pattern). Sort newest
|
||||
# first, skip the first KEEP_IMAGES, drop the rest. :latest is never
|
||||
# touched. The pattern is strict enough that an arbitrary user-supplied
|
||||
# tag (e.g. 'dev') can't be accidentally caught.
|
||||
mapfile -t old_tags < <(
|
||||
podman image list --format '{{.Tag}}' "$IMAGE_NAME" \
|
||||
| grep -E '^[0-9]{8}-[0-9]{6}$' \
|
||||
| sort -r \
|
||||
| awk -v keep="$KEEP_IMAGES" 'NR > keep'
|
||||
)
|
||||
if [[ ${#old_tags[@]} -eq 0 ]]; then
|
||||
log "Nothing to prune (≤${KEEP_IMAGES} timestamped images present)"
|
||||
return
|
||||
fi
|
||||
log "Pruning ${#old_tags[@]} old image(s) (keeping ${KEEP_IMAGES} most recent)"
|
||||
for tag in "${old_tags[@]}"; do
|
||||
log " - ${IMAGE_NAME}:${tag}"
|
||||
podman rmi "${IMAGE_NAME}:${tag}" >/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [command]
|
||||
|
||||
Commands:
|
||||
deploy (default) build → run → prune
|
||||
build Build and tag image only
|
||||
run Run/restart container from :latest (no rebuild)
|
||||
prune Drop old timestamped images
|
||||
config Print resolved configuration and exit
|
||||
|
||||
Configuration is read from deploy.env if present. See README.md.
|
||||
EOF
|
||||
}
|
||||
|
||||
action_config() {
|
||||
cat <<EOF
|
||||
IMAGE_NAME=$IMAGE_NAME
|
||||
CONTAINER_NAME=$CONTAINER_NAME
|
||||
VOLUME_NAME=$VOLUME_NAME
|
||||
HOST_PORT=$HOST_PORT
|
||||
BIND_ADDR=$BIND_ADDR
|
||||
KEEP_IMAGES=$KEEP_IMAGES
|
||||
PUBLIC_BASE_URL=$PUBLIC_BASE_URL
|
||||
EXTRA_PODMAN_RUN_ARGS=$EXTRA_PODMAN_RUN_ARGS
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
TIMESTAMP_TAG=$TIMESTAMP_TAG
|
||||
GIT_REVISION=$GIT_REVISION
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Dispatch -------------------------------------------------------------
|
||||
cmd="${1:-deploy}"
|
||||
case "$cmd" in
|
||||
build) action_build ;;
|
||||
run) action_run ;;
|
||||
prune) action_prune ;;
|
||||
config) action_config ;;
|
||||
deploy) action_build; action_run; action_prune ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) echo "Unknown command: $cmd" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
Loading…
Add table
Add a link
Reference in a new issue