diff --git a/.dockerignore b/.dockerignore index 6149b4f2..8d9b90e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,19 @@ .git .gitignore +.github +.vscode +.idea tmp bin +build +node_modules +lib +public/js +public/css +public/mix-manifest.json +spec +deploy yarn-error.log start_server +*.log +.DS_Store diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..6b4d185f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,76 @@ +name: Deploy + +on: + push: + branches: [production] + workflow_dispatch: + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set lowercase image name + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + push: true + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max,ignore-error=true + + - name: Deploy over SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.SSH_HOST }} + port: ${{ secrets.SSH_PORT }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + script: | + set -euo pipefail + cd "${{ secrets.DEPLOY_PATH }}" + export APP_IMAGE="${{ env.IMAGE }}:${{ github.sha }}" + sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${APP_IMAGE}|" .env + docker compose pull app + docker compose up -d --remove-orphans + + # Health check: wait up to 60s for container to stay running + for i in {1..12}; do + sleep 5 + status=$(docker compose ps --format '{{.State}}' app) + if [ "$status" = "running" ]; then + echo "App is running." + break + fi + if [ "$i" -eq 12 ]; then + echo "App failed to start. Logs:" + docker compose logs --tail=100 app + exit 1 + fi + done + + docker image prune -f --filter "until=168h" diff --git a/config/html_page.cr b/config/html_page.cr index dca168e5..bb76930a 100644 --- a/config/html_page.cr +++ b/config/html_page.cr @@ -1,3 +1,7 @@ Lucky::HTMLPage.configure do |settings| settings.render_component_comments = !LuckyEnv.production? end + +@[Lucky::SvgInliner::Path("src/icons")] +module Lucky::SvgInliner +end diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 00000000..f409d8ee --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,9 @@ +# Copy to .env on the server and fill in. +# Caddy reads APP_DOMAIN to provision a Let's Encrypt cert for that hostname. +APP_DOMAIN=luckyframework.org + +# Generate locally with: lucky gen.secret_key +SECRET_KEY_BASE= + +# Image to pull from GHCR. The deploy workflow updates this tag on each push. +APP_IMAGE=ghcr.io/luckyframework/lucky_website:latest diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 00000000..4cceb534 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,4 @@ +{$APP_DOMAIN} { + encode zstd gzip + reverse_proxy app:8080 +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 00000000..ea2410d5 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,61 @@ +ARG CRYSTAL_VERSION=1.16.3 + +# Install Crystal shards (production set) +FROM crystallang/crystal:$CRYSTAL_VERSION AS crystal_dependencies +WORKDIR /shards +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + build-essential \ + cmake \ + && rm -rf /var/lib/apt/lists/* +ENV SKIP_LUCKY_TASK_PRECOMPILATION=1 +COPY shard.yml shard.lock shard.override.yml ./ +RUN shards install --production + +# Install JS deps and build production CSS/JS via Bun +FROM oven/bun:1 AS frontend_build +WORKDIR /frontend +COPY package.json bun.lock ./ +COPY --from=crystal_dependencies /shards/lib/lucky/src/bun lib/lucky/src/bun +RUN bun install --frozen-lockfile +COPY . . +RUN bun run prod + +# Compile the Crystal webserver binary +FROM crystallang/crystal:$CRYSTAL_VERSION AS lucky_binaries_build +WORKDIR /binaries_build +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* +ENV LUCKY_ENV=production +COPY . . +COPY --from=crystal_dependencies /shards/lib lib +COPY --from=frontend_build /frontend/public public +RUN shards build --production --release \ + && mv ./bin/website_v2 /usr/local/bin/webserver \ + && chmod +x /usr/local/bin/webserver + +# Slim runtime image +FROM ubuntu:24.04 AS webserver +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + ca-certificates \ + curl \ + libevent-dev \ + libpcre2-dev \ + libssl-dev \ + libxml2-dev \ + libyaml-dev \ + tzdata \ + zlib1g-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +WORKDIR /app +ENV LUCKY_ENV=production +ENV PORT=8080 +EXPOSE 8080 +COPY --from=lucky_binaries_build /usr/local/bin/webserver webserver +COPY --from=frontend_build /frontend/public public +COPY ./config/watch.yml config/ +CMD ["./webserver"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..54b94cb5 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,47 @@ +# Deploy + +Production deployment to a single Docker host (e.g. Hetzner) with Caddy in front. + +## Architecture + +- GitHub Actions builds the image (using `deploy/Dockerfile`) and pushes it to GHCR. +- The server runs `docker compose` with two services: `caddy` (reverse proxy + Let's Encrypt) and `app` (the Lucky binary). +- A deploy = `docker compose pull app && docker compose up -d app`. + +## One-time server setup + +1. Provision a Ubuntu 24.04 box, harden it, enable `unattended-upgrades` with automatic reboots. +2. Install Docker Engine + Compose plugin. +3. Point your DNS A record at the server. +4. Create a deploy directory and copy these files into it: + ``` + /opt/lucky_website/ + docker-compose.yml + Caddyfile + .env + ``` +5. Fill in `.env` from `.env.example`. Generate the secret key with `lucky gen.secret_key` on your laptop. +6. Log in to GHCR so the server can pull private images (skip if the package is public): + ``` + echo $GHCR_PAT | docker login ghcr.io -u --password-stdin + ``` +7. Start it: + ``` + docker compose up -d + ``` + Caddy will request a certificate on first request to the domain. + +## GitHub Actions secrets + +Set these on the repo: + +- `SSH_HOST` — server hostname or IP +- `SSH_USER` — deploy user (must be in the `docker` group) +- `SSH_KEY` — private key matching an authorized key on the server +- `DEPLOY_PATH` — absolute path to the deploy directory on the server, e.g. `/opt/lucky_website` + +The workflow uses the built-in `GITHUB_TOKEN` to push to GHCR; no extra secret needed. + +## Updating + +Push to `main`. The workflow builds, pushes `:latest` and `:`, then SSHes in and runs `docker compose pull app && docker compose up -d app`. Brief downtime during the swap. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..b8a2bebc --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,31 @@ +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + APP_DOMAIN: ${APP_DOMAIN} + depends_on: + - app + + app: + image: ${APP_IMAGE} + restart: unless-stopped + environment: + LUCKY_ENV: production + PORT: 8080 + APP_DOMAIN: https://${APP_DOMAIN} + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + SEND_GRID_KEY: unused + expose: + - "8080" + +volumes: + caddy_data: + caddy_config: diff --git a/src/utils/custom_markdown_renderer.cr b/src/utils/custom_markdown_renderer.cr index 990f8eb6..32af5e89 100644 --- a/src/utils/custom_markdown_renderer.cr +++ b/src/utils/custom_markdown_renderer.cr @@ -1,8 +1,15 @@ require "./html_autolink" class CustomMarkdownRenderer - COPY_ICON_SVG = File.read("public/assets/icons/copy.svg") - TICK_ICON_SVG = File.read("public/assets/icons/tick.svg") + include Lucky::SvgInliner + + # This is a workaround to make the `inline_svg` method work here. + def self.raw(content : String) : String + content + end + + COPY_ICON_SVG = inline_svg("copy") + TICK_ICON_SVG = inline_svg("tick") def self.render_to_html(content : String) : String html(content).lines.map do |line|