Building botch.sh β from zero to deployed
I wanted a simple, clean personal website. A landing page, an about page, a photo gallery, and maybe a blog. Nothing fancy, but properly set up β with Docker, CI/CD, and automatic deployments. Hereβs how botch.sh came to life.
The tech stack
Before writing any code, I settled on a stack:
- Astro as a static site generator β fast, simple, no unnecessary JavaScript
- Tailwind CSS for styling
- Nginx Alpine to serve the static build
- Docker with a multi-stage build (Node for building, Nginx for serving)
- GitLab CI/CD for automatic builds and deployments
- Traefik as reverse proxy with Letβs Encrypt SSL (already running on the VPS)
The idea: push to main, GitLab builds the Docker image, pushes it to the registry, SSHs into the VPS, pulls the image, done. No manual steps after the initial setup.
Setting up the project
I started with a fresh Astro project and added Tailwind:
npm create astro@latest
The project uses a custom retro font (VCR OSD Neue) and a dark theme with white accents. The whole site feels like a terminal β which fits the vibe.
Project structure
src/
βββ layouts/
β βββ Layout.astro # Base layout with head, nav, footer
βββ pages/
β βββ index.astro # Landing page
β βββ about.astro # About me
β βββ gallery.astro # Photo gallery with lightbox
β βββ blog/ # Blog pages
βββ components/
β βββ Hero.astro # Terminal-style hero section
β βββ Navbar.astro # Navigation
β βββ Footer.astro # Footer with binary background
β βββ Section.astro # Reusable section wrapper
βββ styles/
βββ global.css # Font + custom styles
The hero section
The landing page has a terminal-style hero with a fake shell session:
$ whoami
botch.sh
$ uptime
up 42 years, 32 days
$ cat /etc/passion
Tech, selfhosting, 3D printing
FPV, MTB, Guitar
$ _
The uptime line is actually dynamic β it calculates the real time since a birthday using client-side JavaScript and updates every minute. If JS is disabled, it falls back to βup ??? daysβ.
const birthday = new Date("YYYY-MM-DD");
function updateUptime() {
const now = new Date();
const diff = now - birthday;
const days = Math.floor(diff / 86400000);
const years = Math.floor(days / 365.25);
const remainDays = Math.floor(days - years * 365.25);
document.getElementById("uptime").textContent =
"up " + years + " years, " + remainDays + " days";
}
updateUptime();
setInterval(updateUptime, 60000);
The photo gallery
The gallery page uses Astroβs import.meta.glob to automatically pick up all images from src/assets/gallery/. No config needed β just drop images in the folder and they show up in a responsive CSS grid.
Clicking an image opens a custom lightbox overlay. No external library, just plain HTML/CSS/JS. Close it with the X button, clicking outside, or pressing Escape.
The tagline
After going back and forth on options, I landed on:
sudo make everything
Short, nerdy, fits the terminal theme.
Docker setup
The Dockerfile is a simple multi-stage build:
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
The Nginx config handles clean URLs, gzip compression, and caching headers for static assets:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml;
gzip_min_length 256;
location ~* \.(css|js|jpg|jpeg|png|webp|avif|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
}
docker-compose.yml for the VPS
The compose file references the GitLab Container Registry and uses Traefik labels for automatic routing and SSL:
services:
website:
image: registry.gitlab.com/botch.sh/botch.sh-website:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.botchsh.rule=Host(`botch.sh`)"
- "traefik.http.routers.botchsh.entrypoints=websecure"
- "traefik.http.routers.botchsh.tls=true"
- "traefik.http.routers.botchsh.tls.certresolver=mytlschallenge"
- "traefik.http.routers.botchsh-http.rule=Host(`botch.sh`)"
- "traefik.http.routers.botchsh-http.entrypoints=web"
- "traefik.http.routers.botchsh-http.middlewares=redirect-to-https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
networks:
- traefik-proxy
networks:
traefik-proxy:
external: true
GitLab CI/CD
The pipeline has two stages β build and deploy:
stages:
- build
- deploy
build:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
script:
- ssh -o StrictHostKeyChecking=no $VPS_USER@$VPS_HOST "
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY &&
docker pull $CI_REGISTRY_IMAGE:latest &&
cd /opt/botchsh &&
docker compose up -d --force-recreate"
only:
- main
Three CI/CD variables need to be set in GitLab under Settings > CI/CD > Variables:
SSH_PRIVATE_KEYβ the private key for SSH access to the VPSVPS_USERβ SSH username on the VPSVPS_HOSTβ IP or hostname of the VPS
Deploying β the fun part
Preparing the VPS
On the VPS, I created the directory and placed the docker-compose.yml:
mkdir -p /opt/botchsh
nano /opt/botchsh/docker-compose.yml
The Traefik network already existed from my n8n setup, so no extra network creation needed.
SSH key for the pipeline
I generated a dedicated deploy key and copied the public key to the VPS:
ssh-keygen -t ed25519 -C "gitlab-deploy" -f ~/.ssh/gitlab_deploy
ssh-copy-id -f -i ~/.ssh/gitlab_deploy.pub user@vps-ip
The private key goes into the GitLab CI/CD variable SSH_PRIVATE_KEY. Quick note: GitLab wonβt let you mask the variable because SSH keys contain spaces/newlines. Set Masked to No but keep Protected on Yes.
DNS
Added an A record pointing botch.sh to the VPS IP at the domain registrar.
First push
git push origin main
The pipeline ran, built the image, pushed it to the registry, SSHed into the VPS, and⦠failed.
Things that went wrong
Because of course they did.
YAML parsing error
What else?
Wrong Docker network
The website container ended up in a network called traefik instead of traefik-proxy (which is what my Traefik instance actually uses). The containers couldnβt talk to each other β classic 504 Gateway Timeout.
Fix: make sure the network name in docker-compose.yml matches exactly. Then docker compose down && docker compose up -d to force recreation.
Missing TLS flag
Traefik wasnβt issuing an SSL certificate. Comparing my config with the working n8n setup, I noticed I was missing traefik.http.routers.botchsh.tls=true. The certresolver alone isnβt enough β you need to explicitly enable TLS on the router.
Certificate resolver name
My Traefik uses mytlschallenge as the cert resolver name, not letsencrypt (which is what most tutorials use). One grep through the Traefik config revealed the mismatch.
The result
Push to main. Pipeline builds. Image gets pushed. VPS pulls. Container restarts. Traefik routes. SSL works.
The whole deployment takes about two minutes from push to live.
Things may break. Things may change. Things may be slightly overengineered.
But thatβs kind of the point.