Skip to content

vedicreader/vpseasy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vpseasy

pub_keys = load_pub_keys()
print(f'{len(pub_keys)} key(s) found')
if pub_keys: print(pub_keys[0][:3] + '...')
4 key(s) found
ssh...

Cloud-init

multi_init() — local Multipass VMs (no UFW). vps_init() — production (UFW, fail2ban, Docker).

_vm = 'testvm'
mi = multi_init(_vm, docker=False)   # docker=False: skip install+reboot, much faster for local testing
print(mi.yaml)
#cloud-config
hostname: testvm
preserve_hostname: false
packages:
- curl
package_update: true
package_upgrade: true
disable_root: true
ssh_pwauth: false
users:
- name: deploy
  groups:
  - sudo
  shell: /bin/bash
  sudo:
  - ALL=(ALL) NOPASSWD:ALL
  ssh_authorized_keys:
  - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG5vF0hxKfho9gZ9nWIp5GIq+UDkZTQ+/v1lgzp+bk5K 71293@MELMAC-71293
ci = vps_init('demo-prod')
print(ci.yaml)
#cloud-config
hostname: demo-prod
preserve_hostname: false
packages:
- curl
- fail2ban
- unattended-upgrades
package_update: true
package_upgrade: true
disable_root: true
ssh_pwauth: false
users:
- name: deploy
  groups:
  - sudo
  shell: /bin/bash
  sudo:
  - ALL=(ALL) NOPASSWD:ALL
  ssh_authorized_keys:
  - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+XjqpWlA8Zcct/3Py1OasAupD8py5/oUlxI4359V8z 71293@MELMAC-71293
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker deploy
- systemctl enable --now docker
- ufw default deny incoming
- ufw default allow outgoing
- ufw logging off
- ufw allow 22/tcp
- ufw --force enable
apt:
  conf: 'APT::Periodic::Update-Package-Lists "1";

    APT::Periodic::Download-Upgradeable-Packages "1";

    APT::Periodic::AutocleanInterval "7";

    APT::Periodic::Unattended-Upgrade "0";

    Unattended-Upgrade::Automatic-Reboot "false";

    '
write_files:
- path: /etc/logrotate.d/00-cloud-init-global
  owner: root:root
  permissions: '0644'
  content: "/var/log/*.log {\n    weekly\n    rotate 7\n    compress\n    su root adm\n    create\n    missingok\n}\n"
power_state:
  mode: reboot
  message: Rebooting
  timeout: 1
  condition: true

Local testing with Multipass

Requires Multipass installed. Pass cloud_init=mi directly to mp.launch(). Use docker=True in multi_init() if your app needs Docker pre-installed (adds ~2 min for install + reboot).

import tempfile
_app = Path(tempfile.mkdtemp()) / 'myapp'
_app.mkdir()
(_app / 'docker-compose.yml').write_text('services:\n  app:\n    image: nginx:alpine\n')
41
mp = Multipass()
try: mp.rm(_vm, purge=True)
except: pass
vm = mp.launch(_vm, image='24.04', cpus=1, memory='1G', disk='10G', cloud_init=mi)
ip = mp.ip(vm.name)
print(f'VM at {ip}, key: {vm.key}')
Creating testvm  Configuring testvm  Starting testvm  Waiting for initialization to complete  Launched: testvm
VM at 192.168.2.56, key: /Users/71293/.ssh/testvm
deploy_mp(_vm, src=_app)
Resolved SSH key from name slug: /Users/71293/.ssh/testvm

Warning: Permanently added '192.168.2.56' (ED25519) to the list of known hosts.

Ensured remote path /srv/app exists and is writable by deploy
Resolved SSH key from name slug: /Users/71293/.ssh/testvm
Running rsync: rsync -az --delete -e ssh -o StrictHostKeyChecking=accept-new -i /Users/71293/.ssh/testvm /var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmp8vrta37_/myapp/ deploy@192.168.2.56:/srv/app/
Rsync completed successfully
Resolved SSH key from name slug: /Users/71293/.ssh/testvm
mp.rm(_vm)

Provision and deploy on Hetzner

Set HCLOUD_TOKEN in your environment. hetzner_deploy() provisions the server, waits for cloud-init, and deploys in one call. It’s idempotent — re-running against an existing server just redeploys.

hz = Hetzner()
svr = hetzner_deploy('myapp-prod',_app, hz) # hz is not required
print(f'Deployed at {svr.name}, key: {svr.key}')
Server myapp-prod provisioning at 95.216.194.42 ...
SSH to host 95.216.194.42 check succeeded
cloud-init status: running
cloud-init status: running
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: running
cloud-init status: running
cloud-init status: running
cloud-init status: running
cloud-init status: running
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: unknown
cloud-init status: done
Ensured remote path /srv/app exists and is writable by deploy
Running rsync: rsync -az --delete -e ssh -o StrictHostKeyChecking=accept-new -i /Users/71293/.ssh/myapp-prod /var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmpo9ofr56s/myapp/ deploy@95.216.194.42:/srv/app/
Rsync completed successfully
Docker info: Client: Docker Engine - Community
 Version:    29.4.3
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.33.0
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v5.1.3
    Path:     /usr/libexec/docker/cli-plugins/docker-compose
  model: Docker Model Runner (Docker Inc.)
    Version:  v1.1.37
    Path:     /usr/libexec/docker/cli-plugins/docker-model

Server:
 Containers: 0
  Running: 0
  Paused: 0
  Stopped: 0
 Images: 0
 Server Version: 29.4.3
 Storage Driver: overlayfs
  driver-type: io.containerd.snapshotter.v1
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 CDI spec directories:
  /etc/cdi
  /var/run/cdi
 Swarm: inactive
 Runtimes: runc io.containerd.runc.v2
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 77c84241c7cbdd9b4eca2591793e3d4f4317c590
 runc version: v1.3.5-0-g488fc13e
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.8.0-111-generic
 Operating System: Ubuntu 24.04.4 LTS
 OSType: linux
 Architecture: x86_64
 CPUs: 2
 Total Memory: 3.73GiB
 Name: myapp-prod
 ID: 16846d84-4424-459d-925b-2df55ed21703
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: false
 Insecure Registries:
  ::1/128
  127.0.0.0/8
 Live Restore Enabled: false
 Firewall Backend: iptables
docker-compose check output: /srv/app/docker-compose.yml
docker compose ran with build → 
Deployed at myapp-prod, key: /Users/71293/.ssh/myapp-prod
o = lambda: [s['name'] for s in hz.servers()]
print('servers: ', o())
hz.delete('myapp-prod')
print('Deleted server.')
print('servers', o())
servers:  ['vedicreader-cx32-hel', 'myapp-prod']
Deleted server.
servers ['vedicreader-cx32-hel']

Docker Compose helpers

Any app that dockeasy can build — FastHTML, FastAPI, Go, Rust, Node — follows the same production Compose shape when deployed behind Cloudflare Tunnel: an app service, a caddy reverse proxy, a cloudflared tunnel container, a shared web network, and two named volumes for Caddy state.

caddy_stack() generates that structure from a domain and any dockeasy Dockerfile object. vols_to_binds() converts absolute container paths to local bind mounts. The root= argument saves all three files (Dockerfile, docker-compose.yml, Caddyfile); without it the Compose object is returned without writing anything.

d = Path(tempfile.mkdtemp())
df = fasthtml_app(pkgs=['sqlite3'], vols=['/app/data'], healthcheck='/health')
c = caddy_stack('myapp.example.com', df, vols=['/app/data'], root=d)
print(c)
services:
  app:
    build: .
    volumes:
    - ./data:/app/data
    env_file:
    - .env
    restart: unless-stopped
    networks:
    - web
  caddy:
    image: caddy:2
    depends_on:
    - app
    volumes:
    - /private/var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmp_dtwwgkg/Caddyfile:/etc/caddy/Caddyfile
    - caddy_data:/data
    - caddy_config:/config
    networks:
    - web
    restart: unless-stopped
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run --url http://caddy
    environment:
    - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}
    networks:
    - web
    restart: unless-stopped
networks:
  web: null
volumes:
  caddy_data: null
  caddy_config: null

Install agent skill

Copies SKILL.md to .agents/skills/vpseasy/ (project-local) and ~/.claude/skills/vpseasy/ (global Claude Code).

mv_skill_md(dry_run=True)
Would copy to: ['.agents/skills/vpseasy/SKILL.md', '/Users/71293/.claude/skills/vpseasy/SKILL.md']

API reference

Symbol Description
load_pub_keys(paths=None) Read ~/.ssh/id_*.pub -> list of strings
gen_key(slug, key_dir=None) Generate ed25519 pair -> AttrDict(key, pub, pub_str)
multi_init(hostname, pub_keys, ...) Multipass cloud-init YAML -> AttrDict(yaml, key)
vps_init(hostname, pub_keys, ...) Production cloud-init YAML -> AttrDict(yaml, key)
Multipass Launch / list / exec / delete local Ubuntu VMs
deploy_mp(name, src, path, build) Sync dir + docker compose up in Multipass VM
Hetzner Create / list / delete Hetzner Cloud servers
hetzner_deploy(name, src, ...) Full pipeline: provision -> wait -> deploy (idempotent)
wait_ssh(host, u, k, tout) Poll until SSH accepts connections
wait_ready(host, u, k, tout) Poll SSH then cloud-init until done
chk_cloud_init(host, u, k) Return cloud-init status string
chk_docker(host, u, k) Verify Docker daemon running
run_ssh(host, *cmds, ...) Run commands over SSH
sync(host, src, path, ...) Rsync local dir to remote
deploy(host, src, path, ...) sync + docker compose up -d
vols_to_binds(vols) ["/app/data"] -> ["./data:/app/data"] for Compose bind mounts
caddy_stack(domain, df, ...) Compose file: app + caddy + cloudflared + web network + caddy volumes
mv_skill_md(dry_run, dir) Install agent SKILL.md

About

create resources, deploy code in prod and dev. supports hetzner and multipass

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors