This commit is contained in:
Josh 2026-01-09 00:22:05 +02:00
commit 52843aa9e7
13 changed files with 920 additions and 0 deletions

109
files/cloud-init/ops.yml Normal file
View file

@ -0,0 +1,109 @@
#cloud-config
package_update: true
package_upgrade: true
packages:
- docker.io
- docker-compose-v2
- git
- curl
- sqlite3
users:
- name: deploy
groups: docker, sudo
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ${ssh_public_key}
write_files:
- path: /opt/writekit/.env
permissions: '0600'
content: |
${indent(6, env_file)}
- path: /opt/writekit/docker-compose.yml
permissions: '0644'
content: |
${indent(6, docker_compose)}
- path: /etc/docker/daemon.json
permissions: '0644'
content: |
{
"insecure-registries": ["10.0.0.3:5000"],
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
- path: /opt/writekit/.ssh/deploy_key
permissions: '0600'
content: |
${indent(6, deploy_ssh_private_key)}
- path: /opt/writekit/setup-forgejo-oauth.sh
permissions: '0755'
content: |
#!/bin/bash
set -a
. /opt/writekit/.env
set +a
DB="/var/lib/docker/volumes/writekit_forgejo-data/_data/gitea/gitea.db"
for i in {1..60}; do
[ -f "$$DB" ] && break
sleep 5
done
sleep 10
sqlite3 "$$DB" <<EOF
INSERT OR IGNORE INTO login_source (type, name, is_active, is_sync_enabled, cfg, created_unix, updated_unix)
VALUES (6, 'GitHub', 1, 0, '{"Provider":"github","ClientID":"$$GITHUB_CLIENT_ID","ClientSecret":"$$GITHUB_CLIENT_SECRET","OpenIDConnectAutoDiscoveryURL":"","CustomURLMapping":null,"IconURL":"","Scopes":["read:user","user:email"],"RequiredClaimName":"","RequiredClaimValue":"","GroupClaimName":"","AdminGroup":"","RestrictedGroup":"","GroupTeamMap":"","GroupTeamMapRemoval":false}', strftime('%s','now'), strftime('%s','now'));
INSERT OR IGNORE INTO oauth2_application (uid, name, client_id, client_secret, confidential_client, redirect_uris, created_unix, updated_unix)
VALUES (0, 'Woodpecker CI', '$$WOODPECKER_FORGEJO_CLIENT', '$$WOODPECKER_FORGEJO_SECRET', 1, 'https://ci.$$DOMAIN/authorize', strftime('%s','now'), strftime('%s','now'));
EOF
- path: /opt/writekit/promote-admin.sh
permissions: '0755'
content: |
#!/bin/bash
set -a
. /opt/writekit/.env
set +a
cd /opt/writekit
docker compose exec -T forgejo gitea admin user change-password --username "$$WOODPECKER_ADMIN" --password "temppass123" 2>/dev/null || true
docker compose exec -T forgejo gitea admin user create --username "$$WOODPECKER_ADMIN" --email "$${WOODPECKER_ADMIN}@localhost" --password "temppass123" --admin 2>/dev/null || \
docker compose exec -T forgejo gitea admin user change-password --username "$$WOODPECKER_ADMIN" --must-change-password=false 2>/dev/null
sqlite3 "/var/lib/docker/volumes/writekit_forgejo-data/_data/gitea/gitea.db" "UPDATE user SET is_admin=1 WHERE lower_name='$$(echo $$WOODPECKER_ADMIN | tr '[:upper:]' '[:lower:]')';"
echo "User $$WOODPECKER_ADMIN promoted to admin"
runcmd:
- systemctl enable docker
- systemctl start docker
- mkdir -p /opt/writekit/.ssh
- chown -R deploy:deploy /opt/writekit
- |
set -a
. /opt/writekit/.env
set +a
cd /opt/writekit && docker compose up -d
- /opt/writekit/setup-forgejo-oauth.sh
- |
for i in {1..30}; do
ssh-keyscan -H 10.0.0.2 >> /opt/writekit/.ssh/known_hosts 2>/dev/null && break
sleep 10
done
chown deploy:deploy /opt/writekit/.ssh/known_hosts
final_message: "WriteKit ops server ready after $$UPTIME seconds"

58
files/cloud-init/prod.yml Normal file
View file

@ -0,0 +1,58 @@
#cloud-config
package_update: true
package_upgrade: true
packages:
- docker.io
- docker-compose-v2
- git
- curl
users:
- name: deploy
groups: docker, sudo
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ${ssh_public_key}
- ${deploy_ssh_public_key}
write_files:
- path: /opt/writekit/.env
permissions: '0600'
content: |
${indent(6, env_file)}
- path: /opt/writekit/docker-compose.yml
permissions: '0644'
content: |
${indent(6, docker_compose)}
- path: /etc/docker/daemon.json
permissions: '0644'
content: |
{
"insecure-registries": ["10.0.0.3:5000"],
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
runcmd:
- systemctl enable docker
- systemctl start docker
- mkdir -p /opt/writekit/data/tenants
- chown -R deploy:deploy /opt/writekit
- |
set -a
. /opt/writekit/.env
set +a
cd /opt/writekit && docker compose up -d postgres traefik
sleep 10
docker compose exec -T postgres psql -U writekit -c "CREATE DATABASE writekit_staging;" || true
final_message: "WriteKit prod server ready after $$UPTIME seconds"

12
files/env/ops.env.example vendored Normal file
View file

@ -0,0 +1,12 @@
DOMAIN=writekit.dev
ACME_EMAIL=you@example.com
CF_DNS_API_TOKEN=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
WOODPECKER_AGENT_SECRET=
WOODPECKER_FORGEJO_CLIENT=
WOODPECKER_FORGEJO_SECRET=
WOODPECKER_ADMIN=josh

41
files/env/prod.env.example vendored Normal file
View file

@ -0,0 +1,41 @@
DOMAIN=writekit.dev
REGISTRY_URL=10.0.0.3:5000
ACME_EMAIL=you@example.com
PG_PASSWORD=
CF_DNS_API_TOKEN=
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=writekit-assets
R2_PUBLIC_URL=https://assets.writekit.dev
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
SESSION_SECRET=
LEMON_API_KEY=
LEMON_STORE_ID=
LEMON_WEBHOOK_SECRET=
WISE_API_KEY=
WISE_PROFILE_ID=
OAUTH2_PROXY_COOKIE_SECRET=
OAUTH2_PROXY_ALLOWED_USERS=josh
GITHUB_CLIENT_ID_STAGING=
GITHUB_CLIENT_SECRET_STAGING=
SESSION_SECRET_STAGING=
LEMON_API_KEY_STAGING=
LEMON_WEBHOOK_SECRET_STAGING=
WISE_API_KEY_STAGING=
WISE_PROFILE_ID_STAGING=

View file

@ -0,0 +1,118 @@
services:
traefik:
image: traefik:v3.6
restart: unless-stopped
ports:
- "80:80"
- "443:443"
command:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=ops
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.cloudflare.acme.dnschallenge=true
- --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.cloudflare.acme.email=$${ACME_EMAIL}
- --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json
environment:
- CF_DNS_API_TOKEN=$${CF_DNS_API_TOKEN}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/letsencrypt
networks:
- ops
registry:
image: registry:2
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- registry-data:/var/lib/registry
environment:
- REGISTRY_STORAGE_DELETE_ENABLED=true
networks:
- ops
forgejo:
image: codeberg.org/forgejo/forgejo:11
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__DOMAIN=source.$${DOMAIN}
- FORGEJO__server__ROOT_URL=https://source.$${DOMAIN}
- FORGEJO__server__SSH_DOMAIN=source.$${DOMAIN}
- FORGEJO__server__SSH_PORT=22
- FORGEJO__server__SSH_LISTEN_PORT=2222
- FORGEJO__database__DB_TYPE=sqlite3
- FORGEJO__service__DISABLE_REGISTRATION=true
- FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION=true
- FORGEJO__webhook__ALLOWED_HOST_LIST=external,loopback,10.0.0.0/24
- FORGEJO__security__INSTALL_LOCK=true
- FORGEJO__actions__ENABLED=false
volumes:
- forgejo-data:/data
ports:
- "2222:2222"
labels:
- traefik.enable=true
- traefik.http.routers.forgejo.rule=Host(`source.$${DOMAIN}`)
- traefik.http.routers.forgejo.tls=true
- traefik.http.routers.forgejo.tls.certresolver=cloudflare
- traefik.http.services.forgejo.loadbalancer.server.port=3000
networks:
- ops
woodpecker:
image: woodpeckerci/woodpecker-server:v3
restart: unless-stopped
environment:
- WOODPECKER_HOST=https://ci.$${DOMAIN}
- WOODPECKER_FORGEJO=true
- WOODPECKER_FORGEJO_URL=http://forgejo:3000
- WOODPECKER_FORGEJO_CLIENT=$${WOODPECKER_FORGEJO_CLIENT}
- WOODPECKER_FORGEJO_SECRET=$${WOODPECKER_FORGEJO_SECRET}
- WOODPECKER_AGENT_SECRET=$${WOODPECKER_AGENT_SECRET}
- WOODPECKER_ADMIN=$${WOODPECKER_ADMIN}
volumes:
- woodpecker-data:/var/lib/woodpecker
depends_on:
- forgejo
labels:
- traefik.enable=true
- traefik.http.routers.woodpecker.rule=Host(`ci.$${DOMAIN}`)
- traefik.http.routers.woodpecker.tls=true
- traefik.http.routers.woodpecker.tls.certresolver=cloudflare
- traefik.http.services.woodpecker.loadbalancer.server.port=8000
networks:
- ops
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
restart: unless-stopped
environment:
- WOODPECKER_SERVER=woodpecker:9000
- WOODPECKER_AGENT_SECRET=$${WOODPECKER_AGENT_SECRET}
- WOODPECKER_BACKEND_DOCKER_NETWORK=writekit_ops
- WOODPECKER_BACKEND_DOCKER_VOLUMES=/opt/writekit/.ssh:/mnt/ssh:ro
- DOCKER_HOST=unix:///var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- woodpecker
networks:
- ops
networks:
ops:
volumes:
traefik-certs:
registry-data:
forgejo-data:
woodpecker-data:

View file

@ -0,0 +1,187 @@
services:
traefik:
image: traefik:v3.6
restart: unless-stopped
ports:
- "80:80"
- "443:443"
command:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=prod
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.cloudflare.acme.dnschallenge=true
- --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.cloudflare.acme.email=$${ACME_EMAIL}
- --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json
environment:
- CF_DNS_API_TOKEN=$${CF_DNS_API_TOKEN}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/letsencrypt
networks:
- prod
imaginary:
image: h2non/imaginary:latest
restart: unless-stopped
command: -enable-url-source=false -max-allowed-size=15728640
networks:
- prod
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=writekit
- POSTGRES_USER=writekit
- POSTGRES_PASSWORD=$${PG_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U writekit"]
interval: 5s
timeout: 5s
retries: 5
networks:
- prod
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
restart: unless-stopped
environment:
- OAUTH2_PROXY_PROVIDER=github
- OAUTH2_PROXY_CLIENT_ID=$${GITHUB_CLIENT_ID_STAGING}
- OAUTH2_PROXY_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET_STAGING}
- OAUTH2_PROXY_COOKIE_SECRET=$${OAUTH2_PROXY_COOKIE_SECRET}
- OAUTH2_PROXY_COOKIE_DOMAINS=.$${DOMAIN}
- OAUTH2_PROXY_EMAIL_DOMAINS=*
- OAUTH2_PROXY_GITHUB_USERS=$${OAUTH2_PROXY_ALLOWED_USERS}
- OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
- OAUTH2_PROXY_REVERSE_PROXY=true
- OAUTH2_PROXY_SET_XAUTHREQUEST=true
- OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
labels:
- traefik.enable=true
- traefik.http.routers.oauth2-proxy.rule=Host(`auth.staging.$${DOMAIN}`)
- traefik.http.routers.oauth2-proxy.tls=true
- traefik.http.routers.oauth2-proxy.tls.certresolver=cloudflare
- traefik.http.services.oauth2-proxy.loadbalancer.server.port=4180
- traefik.http.middlewares.staging-auth.forwardauth.address=http://oauth2-proxy:4180/oauth2/auth
- traefik.http.middlewares.staging-auth.forwardauth.trustForwardHeader=true
- traefik.http.middlewares.staging-auth.forwardauth.authResponseHeaders=X-Auth-Request-User,X-Auth-Request-Email
networks:
- prod
writekit-prod:
image: $${REGISTRY_URL}/writekit:main
restart: unless-stopped
environment:
- ENV=prod
- DOMAIN=$${DOMAIN}
- BASE_URL=https://$${DOMAIN}
- DATABASE_URL=postgres://writekit:$${PG_PASSWORD}@postgres:5432/writekit?sslmode=disable
- DATA_DIR=/data
- R2_ACCOUNT_ID=$${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=$${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=$${R2_SECRET_ACCESS_KEY}
- R2_BUCKET=$${R2_BUCKET}
- R2_PUBLIC_URL=$${R2_PUBLIC_URL}
- GITHUB_CLIENT_ID=$${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET}
- GOOGLE_CLIENT_ID=$${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=$${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=$${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=$${DISCORD_CLIENT_SECRET}
- SESSION_SECRET=$${SESSION_SECRET}
- LEMON_API_KEY=$${LEMON_API_KEY}
- LEMON_STORE_ID=$${LEMON_STORE_ID}
- LEMON_WEBHOOK_SECRET=$${LEMON_WEBHOOK_SECRET}
- WISE_API_KEY=$${WISE_API_KEY}
- WISE_PROFILE_ID=$${WISE_PROFILE_ID}
- IMAGINARY_URL=http://imaginary:9000
volumes:
- tenants-prod:/data
labels:
- traefik.enable=true
- traefik.http.routers.writekit-prod-platform.rule=Host(`$${DOMAIN}`)
- traefik.http.routers.writekit-prod-platform.tls=true
- traefik.http.routers.writekit-prod-platform.tls.certresolver=cloudflare
- traefik.http.routers.writekit-prod-platform.service=writekit-prod
- traefik.http.routers.writekit-prod-blogs.rule=HostRegexp(`^(?!staging\.).+\.$${DOMAIN}$$`)
- traefik.http.routers.writekit-prod-blogs.priority=10
- traefik.http.routers.writekit-prod-blogs.tls=true
- traefik.http.routers.writekit-prod-blogs.tls.certresolver=cloudflare
- traefik.http.routers.writekit-prod-blogs.tls.domains[0].main=$${DOMAIN}
- traefik.http.routers.writekit-prod-blogs.tls.domains[0].sans=*.$${DOMAIN}
- traefik.http.routers.writekit-prod-blogs.service=writekit-prod
- traefik.http.services.writekit-prod.loadbalancer.server.port=8080
depends_on:
postgres:
condition: service_healthy
networks:
- prod
writekit-staging:
image: $${REGISTRY_URL}/writekit:dev
restart: unless-stopped
environment:
- ENV=staging
- DOMAIN=staging.$${DOMAIN}
- BASE_URL=https://staging.$${DOMAIN}
- DATABASE_URL=postgres://writekit:$${PG_PASSWORD}@postgres:5432/writekit_staging?sslmode=disable
- DATA_DIR=/data
- R2_ACCOUNT_ID=$${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=$${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=$${R2_SECRET_ACCESS_KEY}
- R2_BUCKET=$${R2_BUCKET}
- R2_PUBLIC_URL=$${R2_PUBLIC_URL}
- GITHUB_CLIENT_ID=$${GITHUB_CLIENT_ID_STAGING}
- GITHUB_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET_STAGING}
- GOOGLE_CLIENT_ID=$${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=$${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=$${DISCORD_CLIENT_ID}
- DISCORD_CLIENT_SECRET=$${DISCORD_CLIENT_SECRET}
- SESSION_SECRET=$${SESSION_SECRET_STAGING}
- LEMON_API_KEY=$${LEMON_API_KEY_STAGING}
- LEMON_STORE_ID=$${LEMON_STORE_ID}
- LEMON_WEBHOOK_SECRET=$${LEMON_WEBHOOK_SECRET_STAGING}
- WISE_API_KEY=$${WISE_API_KEY_STAGING}
- WISE_PROFILE_ID=$${WISE_PROFILE_ID_STAGING}
- IMAGINARY_URL=http://imaginary:9000
volumes:
- tenants-staging:/data
labels:
- traefik.enable=true
- traefik.http.routers.writekit-staging-platform.rule=Host(`staging.$${DOMAIN}`)
- traefik.http.routers.writekit-staging-platform.tls=true
- traefik.http.routers.writekit-staging-platform.tls.certresolver=cloudflare
- traefik.http.routers.writekit-staging-platform.middlewares=staging-auth
- traefik.http.routers.writekit-staging-platform.service=writekit-staging
- traefik.http.routers.writekit-staging-blogs.rule=HostRegexp(`^.+\.staging\.$${DOMAIN}$$`)
- traefik.http.routers.writekit-staging-blogs.priority=20
- traefik.http.routers.writekit-staging-blogs.tls=true
- traefik.http.routers.writekit-staging-blogs.tls.certresolver=cloudflare
- traefik.http.routers.writekit-staging-blogs.tls.domains[0].main=staging.$${DOMAIN}
- traefik.http.routers.writekit-staging-blogs.tls.domains[0].sans=*.staging.$${DOMAIN}
- traefik.http.routers.writekit-staging-blogs.middlewares=staging-auth
- traefik.http.routers.writekit-staging-blogs.service=writekit-staging
- traefik.http.services.writekit-staging.loadbalancer.server.port=8080
depends_on:
postgres:
condition: service_healthy
networks:
- prod
networks:
prod:
volumes:
traefik-certs:
postgres-data:
tenants-prod:
tenants-staging: