diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40cebb1 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +COMPOSE_PROJECT_NAME=nullcart_promo_prod +CLEARNET_DOMAIN=nullcart.net diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d13161 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env.prod +/deploy/certs +/deploy/certbot/www diff --git a/deploy/scripts/bootstrap-certs.sh b/deploy/scripts/bootstrap-certs.sh new file mode 100755 index 0000000..e211824 --- /dev/null +++ b/deploy/scripts/bootstrap-certs.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +if [[ -z "${CLEARNET_DOMAIN:-}" ]]; then + echo "CLEARNET_DOMAIN is not set in .env.prod" >&2 + exit 1 +fi + +LIVE_DIR="${ROOT_DIR}/deploy/certs/live/${CLEARNET_DOMAIN}" + +if [[ -f "${LIVE_DIR}/fullchain.pem" ]]; then + echo "Certificates already exist at deploy/certs/live/${CLEARNET_DOMAIN}" >&2 + exit 1 +fi + +mkdir -p "$LIVE_DIR" + +openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ + -keyout "${LIVE_DIR}/privkey.pem" \ + -out "${LIVE_DIR}/fullchain.pem" \ + -subj "/CN=${CLEARNET_DOMAIN}" + +echo "Temporary self-signed certificates created at deploy/certs/live/${CLEARNET_DOMAIN}" diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..8e3b296 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2 + exit 1 +fi + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --build + +echo "Pruning unused Docker data older than 24h..." +docker system prune -af --filter "until=24h" diff --git a/deploy/scripts/issue-certs.sh b/deploy/scripts/issue-certs.sh new file mode 100755 index 0000000..0ed0f1b --- /dev/null +++ b/deploy/scripts/issue-certs.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" +CERTBOT_EMAIL="" + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +if [[ -z "${CLEARNET_DOMAIN:-}" ]]; then + echo "CLEARNET_DOMAIN is not set in .env.prod" >&2 + exit 1 +fi + +if [[ -z "$CERTBOT_EMAIL" ]]; then + echo "Pass --email for Let's Encrypt registration." >&2 + usage >&2 + exit 1 +fi + +mkdir -p "${ROOT_DIR}/deploy/certbot/www" "${ROOT_DIR}/deploy/certs" + +docker run --rm \ + -v "${ROOT_DIR}/deploy/certbot/www:/var/www/certbot" \ + -v "${ROOT_DIR}/deploy/certs:/etc/letsencrypt" \ + certbot/certbot certonly \ + --webroot \ + -w /var/www/certbot \ + -d "$CLEARNET_DOMAIN" \ + -d "www.${CLEARNET_DOMAIN}" \ + --email "$CERTBOT_EMAIL" \ + --agree-tos \ + --non-interactive + +if [[ ! -f "${ROOT_DIR}/deploy/certs/live/${CLEARNET_DOMAIN}/fullchain.pem" ]]; then + echo "Expected certificates at deploy/certs/live/${CLEARNET_DOMAIN}" >&2 + exit 1 +fi + +echo "Certificates issued at deploy/certs/live/${CLEARNET_DOMAIN}" +echo "Reload nginx: docker compose --env-file .env.prod -f docker-compose.prod.yml exec nginx nginx -s reload" diff --git a/deploy/scripts/renew-certs.sh b/deploy/scripts/renew-certs.sh new file mode 100755 index 0000000..28dc7c6 --- /dev/null +++ b/deploy/scripts/renew-certs.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}" >&2 + exit 1 +fi + +mkdir -p "${ROOT_DIR}/deploy/certbot/www" "${ROOT_DIR}/deploy/certs" + +docker run --rm \ + -v "${ROOT_DIR}/deploy/certbot/www:/var/www/certbot" \ + -v "${ROOT_DIR}/deploy/certs:/etc/letsencrypt" \ + certbot/certbot renew \ + --webroot \ + -w /var/www/certbot + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec nginx nginx -s reload + +echo "Certificate renewal complete; nginx reloaded." diff --git a/deploy/scripts/show-onion.sh b/deploy/scripts/show-onion.sh new file mode 100755 index 0000000..2021287 --- /dev/null +++ b/deploy/scripts/show-onion.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}" >&2 + exit 1 +fi + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec tor \ + cat /var/lib/tor/hs/hostname diff --git a/deploy/scripts/update.sh b/deploy/scripts/update.sh new file mode 100755 index 0000000..1d5c881 --- /dev/null +++ b/deploy/scripts/update.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +cd "$ROOT_DIR" + +git pull + +"${ROOT_DIR}/deploy/scripts/deploy.sh" diff --git a/deploy/tor/Dockerfile b/deploy/tor/Dockerfile new file mode 100644 index 0000000..41914c9 --- /dev/null +++ b/deploy/tor/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:3.20 + +RUN apk add --no-cache tor + +COPY torrc /etc/tor/torrc + +CMD ["tor", "-f", "/etc/tor/torrc"] diff --git a/deploy/tor/torrc b/deploy/tor/torrc new file mode 100644 index 0000000..90fb64b --- /dev/null +++ b/deploy/tor/torrc @@ -0,0 +1,5 @@ +SocksPort 0 +Log notice stdout + +HiddenServiceDir /var/lib/tor/hs/ +HiddenServicePort 80 nginx:8080 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..e4a8c05 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,29 @@ +services: + nginx: + build: + context: . + dockerfile: nginx/Dockerfile.prod + container_name: ${COMPOSE_PROJECT_NAME}_nginx + restart: unless-stopped + env_file: + - .env.prod + ports: + - '80:80' + - '443:443' + volumes: + - ./deploy/certs:/etc/nginx/certs:ro + - ./deploy/certbot/www:/var/www/certbot:ro + + tor: + build: + context: ./deploy/tor + container_name: ${COMPOSE_PROJECT_NAME}_tor + restart: unless-stopped + volumes: + - nullcart_promo.tor.prod.data:/var/lib/tor + depends_on: + nginx: + condition: service_started + +volumes: + nullcart_promo.tor.prod.data: diff --git a/nginx/Dockerfile.prod b/nginx/Dockerfile.prod new file mode 100644 index 0000000..70dba36 --- /dev/null +++ b/nginx/Dockerfile.prod @@ -0,0 +1,15 @@ +FROM nginx:alpine + +RUN apk add --no-cache gettext \ + && rm /etc/nginx/conf.d/default.conf + +COPY index.html /usr/share/nginx/html/ +COPY css/ /usr/share/nginx/html/css/ +COPY assets/ /usr/share/nginx/html/assets/ + +COPY nginx/conf.d/ /etc/nginx/templates/conf.d/ +COPY nginx/docker-entrypoint.sh /docker-entrypoint.sh + +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/nginx/conf.d/clearnet.conf.template b/nginx/conf.d/clearnet.conf.template new file mode 100644 index 0000000..1bed760 --- /dev/null +++ b/nginx/conf.d/clearnet.conf.template @@ -0,0 +1,27 @@ +server { + listen 80; + server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN}; + + ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/nginx/conf.d/onion.conf b/nginx/conf.d/onion.conf new file mode 100644 index 0000000..db641d0 --- /dev/null +++ b/nginx/conf.d/onion.conf @@ -0,0 +1,11 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/nginx/docker-entrypoint.sh b/nginx/docker-entrypoint.sh new file mode 100755 index 0000000..0116f5f --- /dev/null +++ b/nginx/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +export CLEARNET_DOMAIN +envsubst '${CLEARNET_DOMAIN}' \ + < /etc/nginx/templates/conf.d/clearnet.conf.template \ + > /etc/nginx/conf.d/clearnet.conf + +cp /etc/nginx/templates/conf.d/onion.conf /etc/nginx/conf.d/onion.conf + +exec nginx -g 'daemon off;'