One Caddy on 80/443, every project in its own folder and compose project, deploys from GitHub Actions over SSH
One Caddy owns ports 80/443 on the server, issues TLS certificates and routes each domain to its
project's container. Projects never publish ports and never touch each other: the only place
that knows which domain goes where is sites/.
flowchart LR
user([Internet]) -->|80 / 443| caddy[Caddy]
subgraph server[projects server]
caddy -->|network edge| apps[project containers]
apps -.->|network vpn, optional| vpn[Xray proxy]
end
vpn -.-> api([OpenRouter, Telegram, ...])
| Domain | Project | Containers |
|---|---|---|
| tatiana-shkatova.ru | aimuzov-projects/shkatova-psychologist | shkatova-psychologist, shkatova-chat |
| taplist.aimuzov.online | aimuzov-projects/community-taplist | community-taplist, community-taplist-api |
| codenames.aimuzov.online | aimuzov-projects/codenames | codenames, codenames-relay |
| logist.aimuzov.online | aimuzov-projects/prestige-logist | prestige-logist, prestige-logist-api |
| sublya.aimuzov.online | aimuzov-projects/sublya | sublya-site, sublya-tunnel |
| lazyvimx.aimuzov.online | lazyvimx/site | lazyvimx-site |
| wodlog.aimuzov.online | aimuzov-projects/wodlog | wodlog, wodlog-api |
| wodcare.aimuzov.online | aimuzov-projects/wodcare | wodcare |
Caddyfile shared snippet (compression, security headers) + import of sites/
sites/<project>.caddy one file per site: domain → container
compose.yml caddy and vpn services
deploy/remote.sh deploy script, runs on the server
deploy/site.sh deploy of every site: starts site-deploy@<project> and shows its log
deploy/site-build.sh the build itself, inside site-deploy@.service
deploy/site-deploy@.service systemd unit of a site build, outlives the SSH connection
ssh/10-no-passwords.conf sshd: keys only, no passwords; applied on every deploy
vpn/update.sh builds the Xray config from the subscription
vpn/vpn-update.{service,timer} daily config refresh
docker/docker-prune.{service,timer} weekly cleanup of the Docker build cache
.env.example secrets template (the real .env lives only on the server)
/srv/
infra-server/ ← this repo
<project>/ ← clone of the project repo, built and run in place
- Repos are named
<space>-<project>:infra-server,infra-site-template,infra-site-kit,shkatova-psychologist. The server folder mirrors the repo name. - One project — one folder
/srv/<project>and one compose project with an explicitname:. Containers, volumes and networks get the project prefix, so projects never collide. - Projects publish no host ports. They join the external
edgenetwork and Caddy reaches them by container name. - A project never writes outside its folder.
- Sites deploy with one script,
deploy/site.sh, the key of a site runs it with the project name and the release tag Actions sends as the ssh command (without one, the newestmain). The build runs in the systemd unitsite-deploy@<project>, so a dropped SSH connection doesn't stop it, andsite.shstreams its log to Actions. The lock/tmp/sites-deploy.locklets the server build one site at a time, so a merge into several repos at once doesn't build them in parallel on the small server. The log of the last deploys:journalctl -u site-deploy@<project>. - Every repo, public ones included, is pulled over SSH with its own read-only deploy key. Deploy
keys must be enabled for the organization (
deploy_keys_enabled_for_repositories).
A push to main triggers GitHub Actions: shellcheck and caddy validate first, then an SSH
login to the server. There the key is pinned to deploy/remote.sh, which
pulls main, refreshes the VPN egress and applies the Caddy config. A broken config never
replaces the running one. To redeploy without a push:
gh workflow run deploy.ymlThe key in the server's authorized_keys is restricted to a single command:
command="git -C /srv/infra-server pull --ff-only --quiet && /srv/infra-server/deploy/remote.sh",restrict ssh-ed25519 ... github-actions@infra-server
Its private half is the DEPLOY_SSH_KEY repository secret.
Nothing secret is committed. Secrets live in /srv/infra-server/.env (template:
.env.example):
| Variable | Used by |
|---|---|
VPN_SUBSCRIPTION_URL |
vpn/update.sh — subscription the Xray config is built from |
TAPLIST_EDITOR_HASH |
sites/community-taplist.caddy — bcrypt hash for the editor's basic_auth |
To give a site its own secret, reference it as {$NAME} in sites/<project>.caddy and pass it to
the caddy service in compose.yml one by one, so the rest of .env stays out of Caddy:
environment:
NAME: ${NAME:?NAME is not set, see .env.example}Values containing $ (bcrypt hashes) go in single quotes. A password hash is made with:
docker run --rm -it caddy:2.10-alpine caddy hash-password-
In the project's compose:
name: <project>, a uniquecontainer_nameper service, theedgenetwork asexternal: true, noports:. -
Create a deploy key on the server:
ssh projects 'ssh-keygen -q -t ed25519 -N "" -C "projects-server:<project>" -f ~/.ssh/<project> && printf "Host github-<project>\n\tHostName github.com\n\tUser git\n\tIdentityFile ~/.ssh/<project>\n\tIdentitiesOnly yes\n\n" >> ~/.ssh/config && cat ~/.ssh/<project>.pub'
and add the public key to the repo:
gh repo deploy-key add key.pub -R <org>/<repo> --title projects-server
-
Clone the project:
ssh projects git clone github-<project>:<org>/<repo>.git /srv/<project>
When the repo has
.env.example, fill/srv/<project>/.envfrom it: without that file the build stops before compose. -
For a site, let its GitHub Actions deploy: add the public half of a new key to
/root/.ssh/authorized_keysand the private half to the repo secretDEPLOY_SSH_KEY, then set the repo variableDEPLOY_ENABLEDtotrue, since sites from infra-site-template skip the deploy job without it:command="/srv/infra-server/deploy/site.sh <project>",restrict ssh-ed25519 ... github-actions@<project> -
Add
sites/<project>.caddywithimport commonandreverse_proxy <container_name>:<port>, point the domain's DNS at the server and push tomain.commonsets the security headers, a site's ownPermissions-Policywins over it, seecodenames.caddy. -
The first build: run the site's Deploy by hand or
ssh projects /srv/infra-server/deploy/site.sh <project>.
The server is hosted in Russia, where some external APIs (OpenRouter, Telegram) are unreachable.
The vpn container runs Xray with an HTTP proxy on vpn:3128 and SOCKS on vpn:1080, using the
"Всё и сразу" profile from the subscription as is: Russian addresses go direct, the rest through
European nodes, with fallback chains if those are down.
Projects opt in from their compose:
services:
app:
environment:
HTTPS_PROXY: http://vpn:3128
NODE_USE_ENV_PROXY: '1' # Node 24+ only: without it fetch ignores the proxy
networks:
- vpn
networks:
vpn:
name: vpn
external: truevpn/update.sh rebuilds the config on every deploy and once a day via vpn-update.timer. Check
from the server:
docker run --rm --network vpn curlimages/curl -sx http://vpn:3128 https://www.cloudflare.com/cdn-cgi/trace- Docker Engine from the docker.com apt repository, plus
apt install jq. - Firewall:
ufw allow 22/tcp,ufw allow 80/tcp,ufw allow 443(TCP and UDP for HTTP/3). - Networks:
docker network create edgeanddocker network create vpn. - Deploy key for this repo (see Adding a project), then
git clone github-infra-server:aimuzov-projects/infra-server.git /srv/infra-server. /srv/infra-server/.envfrom.env.example, then rundeploy/remote.shonce.- Shell: root gets fish from dotfiles,
bash stays the login shell and an interactive SSH login switches to fish. Install the
chezmoi_<version>_linux_amd64.debfrom chezmoi releases after checking it againstchecksums.txt, thenchezmoi init --apply --recurse-submodules=false https://github.com/aimuzov/dotfiles.git. After that every deploy of this repo runschezmoi update, so changes of the dotfiles get here with the next deploy (orgh workflow run deploy.yml).
