From 85640b3b45ac58c68bee83e52be4c4180edcad5a Mon Sep 17 00:00:00 2001 From: Wout Date: Sat, 2 May 2026 19:35:33 +0200 Subject: [PATCH 1/7] Add basic setup for caddy an crystal --- .dockerignore | 13 ++++++++ .github/workflows/deploy.yml | 59 ++++++++++++++++++++++++++++++++++ deploy/.env.example | 9 ++++++ deploy/Caddyfile | 4 +++ deploy/Dockerfile | 61 ++++++++++++++++++++++++++++++++++++ deploy/README.md | 47 +++++++++++++++++++++++++++ deploy/docker-compose.yml | 30 ++++++++++++++++++ 7 files changed, 223 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.yml 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..5d902a50 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + env: + IMAGE: ghcr.io/${{ github.repository }} + + steps: + - uses: actions/checkout@v4 + + - 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 + + - name: Deploy over SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.SSH_HOST }} + 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 app + docker image prune -f 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..e8aa6de6 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,30 @@ +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} + expose: + - "8080" + +volumes: + caddy_data: + caddy_config: From c6f46fe0860f2f599f80ccd6a96e386cbccaba33 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 4 May 2026 09:53:59 +0200 Subject: [PATCH 2/7] ci: add healthcheck for deployent --- .github/workflows/deploy.yml | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5d902a50..efde9f6c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy on: push: - branches: [main] + branches: [production] workflow_dispatch: concurrency: @@ -16,12 +16,12 @@ jobs: contents: read packages: write - env: - IMAGE: ghcr.io/${{ github.repository }} - 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 @@ -55,5 +55,21 @@ jobs: 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 app - docker image prune -f + docker compose up -d --remove-orphans app + + # 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" From 6b4522c444d2b287edec4aa2e341b115f74d9209 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 4 May 2026 10:08:11 +0200 Subject: [PATCH 3/7] Ignore errors while caching build --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index efde9f6c..999f4939 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,7 +41,7 @@ jobs: ${{ env.IMAGE }}:latest ${{ env.IMAGE }}:${{ github.sha }} cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=max,ignore-error=true - name: Deploy over SSH uses: appleboy/ssh-action@v1 From 4bb6016d296620da4623ff440e8686e17f982619 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 4 May 2026 10:19:45 +0200 Subject: [PATCH 4/7] Add ssh port to deploy workflow --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 999f4939..e2437258 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,6 +47,7 @@ jobs: uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} + port: ${{ secrets.SSH_PORT }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} script: | From 3103efd44bffe79270cca3aa55cc2d4dd3d7b009 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 4 May 2026 11:34:10 +0200 Subject: [PATCH 5/7] Add unused sendgrid key --- deploy/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e8aa6de6..b8a2bebc 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,6 +22,7 @@ services: PORT: 8080 APP_DOMAIN: https://${APP_DOMAIN} SECRET_KEY_BASE: ${SECRET_KEY_BASE} + SEND_GRID_KEY: unused expose: - "8080" From c31cb4403027779c755679235b14d032a8b69194 Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 4 May 2026 12:09:40 +0200 Subject: [PATCH 6/7] Use inline_svg macro to embed svg icons in markdown renderer --- config/html_page.cr | 4 ++++ src/utils/custom_markdown_renderer.cr | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) 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/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| From ae368a6708b358c7c2a094ca60cafb5b6e0fc45e Mon Sep 17 00:00:00 2001 From: Wout Date: Mon, 4 May 2026 13:16:00 +0200 Subject: [PATCH 7/7] Make deploy workflow restart the full docker-compose setup --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e2437258..6b4d185f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -56,7 +56,7 @@ jobs: 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 app + docker compose up -d --remove-orphans # Health check: wait up to 60s for container to stay running for i in {1..12}; do