| 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 "GitHub sign-in (optional)" |
| 110 | echo "Lets people sign in with GitHub. GitHub imports need it too." |
| 111 | echo "Needs a GitHub OAuth app: https://github.com/settings/applications/new" |
| 112 | echo " Homepage URL: $WEB_APP_URL" |
| 113 | echo " Callback URL: $API_URL/api/auth/callback/github" |
| 114 | github_default=y/N; [[ -n |
| 115 | if confirm "Turn on GitHub sign-in?" |
| 116 | while ask GITHUB_CLIENT_ID " |
| 117 | while ask_secret GITHUB_CLIENT_SECRET " |
| 118 | |
| 119 | say "GitHub imports (optional)" |
| 120 | echo "Lets people import a GitHub repository with its code, releases, issues and pull requests." |
| 121 | import_default=y/N; [[ |
| 122 | if confirm "Turn on GitHub imports?" |
| 123 | COMPOSE_PROFILES=$COMPOSE_PROFILES,importer |
| 124 | IMPORTER_URL=http://importer:3004 |
| 125 | IMPORTER_SECRET=${IMPORTER_SECRET:-$( |
| 126 | else |
| 127 | IMPORTER_URL=""; IMPORTER_SECRET |
| 128 | fi |
| 129 | else |
| 130 | GITHUB_CLIENT_ID=""; GITHUB_CLIENT_SECRET |
| 131 | fi |
| 132 | |
| 133 | say "Secrets (press enter to generate)" |
| 134 | # Existing ones are kept: a new auth secret signs everyone out, a new webhook key |
| 135 | # makes every stored webhook secret unreadable, a new host key warns every client, |
| 136 | # a new signing key unverifies every merge commit the old one signed. |
| 137 | [[ -n ${BETTER_AUTH_SECRET:-} |
| 138 | [[ -n ${WEBHOOK_SECRET_KEY:-} |
| 139 | [[ -n ${GIT_SSH_HOST_KEY:-} |
| 140 | [[ -n ${COMMIT_SIGNING_KEY:-} |
| 141 | BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-$(random |
| 142 | WEBHOOK_SECRET_KEY=${WEBHOOK_SECRET_KEY:-$(random |
| 143 | POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-$(hex |
| 144 | if [[ -z ${GIT_SSH_HOST_KEY |
| 145 | echo "Generating an SSH host key..." |
| 146 | GIT_SSH_HOST_KEY=$(docker run |
| 147 | 'apk add -q openssh-keygen >/dev/null && ssh-keygen -q -t ed25519 -N "" -C ghost -f /k && base64 -w0 /k') |
| 148 | fi |
| 149 | if [[ -z ${COMMIT_SIGNING_KEY |
| 150 | echo "Generating a commit signing key..." |
| 151 | COMMIT_SIGNING_KEY=$(docker run |
| 152 | 'apk add -q gnupg >/dev/null && gpg -q --batch --pinentry-mode loopback --passphrase "" --quick-gen-key Ghost ed25519 sign never 2>/dev/null && gpg --armor --export-secret-keys | base64 -w0') |
| 153 | [[ -n $COMMIT_SIGNING_KEY ]] |
| 154 | fi |
| 155 | |
| 156 | for v in MAIL_PASSWORD |
| 157 | # ponytail: .env values are single-quoted so $ and spaces are literal; a quote would need an escape compose lacks. |
| 158 | [[ ${!v:-} |
| 159 | done |
| 160 | |
| 161 | umask 077 |
| 162 | { |
| 163 | echo "# Written by setup.sh. Re-run it to change answers, or edit and run: docker compose up -d --build" |
| 164 | for v in COMPOSE_PROFILES |
| 165 | POSTGRES_PASSWORD BETTER_AUTH_SECRET AUTH_COOKIE_DOMAIN WEBHOOK_SECRET_KEY \ |
| 166 | GIT_SSH_HOST_KEY COMMIT_SIGNING_KEY GIT_SSH_PORT S3_ENDPOINT S3_BUCKET |
| 167 | EMAIL_SENDER EMAIL_VERIFICATION_ENABLED EMAIL_PROXY EMAIL_PROXY_SECRET DELIVERY_EMAIL |
| 168 | MAIL_HOST MAIL_PORT MAIL_SECURE MAIL_USER MAIL_PASSWORD |
| 169 | GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET IMPORTER_URL IMPORTER_SECRET; |
| 170 | # Empty values are left out: the API reads `EMAIL_PROXY=''` as set. |
| 171 | [[ -z ${!v |
| 172 | done |
| 173 | echo "DATABASE_URL='postgres://ghost:$POSTGRES_PASSWORD@postgres:5432/ghost' |
| 174 | echo "BETTER_AUTH_URL='$API_URL' |
| 175 | echo "AUTH_TRUSTED_ORIGINS='$WEB_APP_URL' |
| 176 | echo "ZOEKT_URL='http://zoekt:6070'" |
| 177 | } >"$ENV_FILE" |
| 178 | |
| 179 | say "Building and starting Ghost. The first build takes several minutes." |
| 180 | docker compose up -d |
| 181 | |
| 182 | say "Ghost is up." |
| 183 | echo " Web $WEB_APP_URL" |
| 184 | echo " API $API_URL (git clone $API_URL |
| 185 | echo " Docs $DOCS_URL" |
| 186 | echo " SSH ssh://git@$SSH_CLONE_HOST/<user>/<repo>.git" |
| 187 | echo |
| 188 | echo "Configuration is in $(pwd) |