| 1 | #!/usr/bin/env bash |
| 2 | # Configures and starts a self-hosted Ghost. Safe to re-run: existing answers in |
| 3 | # .env become the defaults, and generated secrets are never regenerated. |
| 4 | set -euo pipefail |
| 5 | |
| 6 | cd "$(dirname " |
| 7 | ENV_FILE=.env |
| 8 | |
| 9 | # Print an error to stderr and stop setup with exit status 1. |
| 10 | die() { printf |
| 11 | # Print a highlighted setup section heading. |
| 12 | say() { printf |
| 13 | |
| 14 | command -v docker >/dev/null |
| 15 | docker compose version >/dev/null |
| 16 | docker info >/dev/null 2>&1 |
| 17 | |
| 18 | keep="" |
| 19 | if [[ -f $ENV_FILE |
| 20 | set -a; . |
| 21 | read -r -p " |
| 22 | if [[ ! $keep |
| 23 | docker compose up -d |
| 24 | exit |
| 25 | fi |
| 26 | fi |
| 27 | |
| 28 | # ask VAR "question" default: the current value of VAR beats the default. |
| 29 | ask() { |
| 30 | local cur="${ |
| 31 | read -r -p " |
| 32 | printf -v "$1 |
| 33 | } |
| 34 | # Read a hidden answer into the named variable, preserving its value on empty input. |
| 35 | ask_secret() { |
| 36 | local ans |
| 37 | read -r -s -p |
| 38 | echo |
| 39 | [[ -n $ans ]] |
| 40 | return 0 |
| 41 | } |
| 42 | # Return success for yes, or for empty input when the prompt defaults to yes. |
| 43 | confirm() { |
| 44 | local ans |
| 45 | read -r -p " |
| 46 | [[ -z $ans && |
| 47 | } |
| 48 | # Encode the requested number of random bytes as base64 without line breaks. |
| 49 | random() { head |
| 50 | # Encode the requested number of random bytes as hexadecimal without whitespace. |
| 51 | hex() { head |
| 52 | |
| 53 | say "Where will Ghost live?" |
| 54 | echo "A domain (git.example.com) gets HTTPS on ports 80/443, with api. and docs. subdomains." |
| 55 | echo "An IP address or localhost serves plain HTTP on ports 3000 (web), 3001 (api), 3003 (docs)." |
| 56 | last_host=${SSH_CLONE_HOST:-localhost |
| 57 | ask HOST "Domain or IP" |
| 58 | HOST=${HOST#http |
| 59 | |
| 60 | ask GIT_SSH_PORT "SSH port for git clone/push" |
| 61 | |
| 62 | if [[ $HOST == localhost |
| 63 | DOMAIN=""; COMPOSE_PROFILES |
| 64 | WEB_APP_URL="http://$HOST:3000 |
| 65 | else |
| 66 | DOMAIN=$HOST; COMPOSE_PROFILES |
| 67 | WEB_APP_URL="https://$HOST" |
| 68 | echo "Point DNS A/AAAA records for $HOST, api. |
| 69 | fi |
| 70 | SSH_CLONE_HOST="$HOST:$GIT_SSH_PORT |
| 71 | |
| 72 | say "Email (required: verification, password resets, notifications)" |
| 73 | echo " 1) SMTP server" |
| 74 | echo " 2) HTTP email relay (EMAIL_PROXY), also lets apps/delivery retry emails" |
| 75 | default_mail=1; [[ -n |
| 76 | ask MAIL_KIND "Choose 1 or 2" |
| 77 | if [[ $MAIL_KIND == |
| 78 | MAIL_HOST=""; MAIL_USER |
| 79 | while ask EMAIL_PROXY " |
| 80 | ask_secret EMAIL_PROXY_SECRET "Relay secret (empty for none)" |
| 81 | DELIVERY_EMAIL=queue |
| 82 | else |
| 83 | EMAIL_PROXY=""; EMAIL_PROXY_SECRET |
| 84 | while ask MAIL_HOST " |
| 85 | ask MAIL_PORT "SMTP port" |
| 86 | ask MAIL_USER "SMTP username" |
| 87 | while ask_secret MAIL_PASSWORD " |
| 88 | MAIL_SECURE=false; [[ |
| 89 | fi |
| 90 | ask EMAIL_SENDER "Send email as" |
| 91 | EMAIL_VERIFICATION_ENABLED=false |
| 92 | confirm "Require new accounts to verify their email?" && |
| 93 | |
| 94 | say "Storage" |
| 95 | if confirm "Use the bundled S3 storage (RustFS)? Say no for R2, Tigris, MinIO..."; |
| 96 | COMPOSE_PROFILES=${COMPOSE_PROFILES:+ |
| 97 | [[ ${S3_ENDPOINT:-} |
| 98 | S3_ENDPOINT=http://rustfs:9000; S3_BUCKET |
| 99 | S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-ghost |
| 100 | S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-$( |
| 101 | else |
| 102 | [[ ${S3_ENDPOINT:-} |
| 103 | ask S3_ENDPOINT "S3 endpoint URL" |
| 104 | ask S3_BUCKET "Bucket" |
| 105 | ask S3_ACCESS_KEY_ID "Access key ID" |
| 106 | ask_secret S3_SECRET_ACCESS_KEY "Secret access key" |
| 107 | fi |
| 108 | |
| 109 | say "Secrets (press enter to generate)" |
| 110 | # Existing ones are kept: a new auth secret signs everyone out, a new webhook key |
| 111 | # makes every stored webhook secret unreadable, a new host key warns every client. |
| 112 | [[ -n ${BETTER_AUTH_SECRET:-} |
| 113 | [[ -n ${WEBHOOK_SECRET_KEY:-} |
| 114 | [[ -n ${GIT_SSH_HOST_KEY:-} |
| 115 | BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-$(random |
| 116 | WEBHOOK_SECRET_KEY=${WEBHOOK_SECRET_KEY:-$(random |
| 117 | POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-$(hex |
| 118 | if [[ -z ${GIT_SSH_HOST_KEY |
| 119 | echo "Generating an SSH host key..." |
| 120 | GIT_SSH_HOST_KEY=$(docker run |
| 121 | 'apk add -q openssh-keygen >/dev/null && ssh-keygen -q -t ed25519 -N "" -C ghost -f /k && base64 -w0 /k') |
| 122 | fi |
| 123 | |
| 124 | for v in MAIL_PASSWORD |
| 125 | # ponytail: .env values are single-quoted so $ and spaces are literal; a quote would need an escape compose lacks. |
| 126 | [[ ${!v:-} |
| 127 | done |
| 128 | |
| 129 | umask 077 |
| 130 | { |
| 131 | echo "# Written by setup.sh. Re-run it to change answers, or edit and run: docker compose up -d --build" |
| 132 | for v in COMPOSE_PROFILES |
| 133 | POSTGRES_PASSWORD BETTER_AUTH_SECRET AUTH_COOKIE_DOMAIN WEBHOOK_SECRET_KEY \ |
| 134 | GIT_SSH_HOST_KEY GIT_SSH_PORT S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY_ID |
| 135 | EMAIL_SENDER EMAIL_VERIFICATION_ENABLED EMAIL_PROXY EMAIL_PROXY_SECRET DELIVERY_EMAIL |
| 136 | MAIL_HOST MAIL_PORT MAIL_SECURE MAIL_USER MAIL_PASSWORD |
| 137 | # Empty values are left out: the API reads `EMAIL_PROXY=''` as set. |
| 138 | [[ -z ${!v |
| 139 | done |
| 140 | echo "DATABASE_URL='postgres://ghost:$POSTGRES_PASSWORD@postgres:5432/ghost' |
| 141 | echo "BETTER_AUTH_URL='$API_URL' |
| 142 | echo "AUTH_TRUSTED_ORIGINS='$WEB_APP_URL' |
| 143 | echo "ZOEKT_URL='http://zoekt:6070'" |
| 144 | } >"$ENV_FILE" |
| 145 | |
| 146 | say "Building and starting Ghost. The first build takes several minutes." |
| 147 | docker compose up -d |
| 148 | |
| 149 | say "Ghost is up." |
| 150 | echo " Web $WEB_APP_URL" |
| 151 | echo " API $API_URL (git clone $API_URL |
| 152 | echo " Docs $DOCS_URL" |
| 153 | echo " SSH ssh://git@$SSH_CLONE_HOST/<user>/<repo>.git" |
| 154 | echo |
| 155 | echo "Configuration is in $(pwd) |