Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions config/html_page.cr
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{$APP_DOMAIN} {
encode zstd gzip
reverse_proxy app:8080
}
61 changes: 61 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
47 changes: 47 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -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 <github-user> --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 `:<sha>`, then SSHes in and runs `docker compose pull app && docker compose up -d app`. Brief downtime during the swap.
31 changes: 31 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
11 changes: 9 additions & 2 deletions src/utils/custom_markdown_renderer.cr
Original file line number Diff line number Diff line change
@@ -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|
Expand Down