Deploy Markdown services on any VPS using Podman Quadlet - a lightweight alternative to Kubernetes.
Quadlet generates systemd unit files from simple .container, .pod, .network files, providing:
~/.config/containers/systemd/Related:
docs/SECURITY.md - hardening + injection test suitedocs/CLOUDFLARE_WORKERS_COMPARISON.md - when Quadlet VPS beats Workersexamples/ - practical deployments# Initialize with Traefik reverse proxy
pactown quadlet init --domain pactown.com --email admin@pactown.com
# This creates:
# ~/.config/containers/systemd/traefik.container
# ~/.config/containers/systemd/traefik-letsencrypt.volume
# Deploy README.md to docs.pactown.com
pactown quadlet deploy ./README.md \
--domain pactown.com \
--subdomain docs \
--tenant user01 \
--tls
# Access at: https://docs.pactown.com
For more practical “copy & customize” services, see:
examples/email-llm-responder/README.mdexamples/api-gateway-webhooks/README.mdexamples/realtime-notifications/README.md# List services
pactown quadlet list --tenant user01
# View logs
pactown quadlet logs my-service --lines 100
# Interactive shell
pactown quadlet shell --domain pactown.com --tenant user01
pactown quadlet initInitialize Quadlet environment with Traefik reverse proxy.
pactown quadlet init --domain <domain> [--email <email>] [--system]
Options:
--domain, -d - Base domain for Traefik (required)--email, -e - Email for Let’s Encrypt certificates--system - Use system-wide systemd (requires root)pactown quadlet deployDeploy a Markdown file as a web service.
pactown quadlet deploy <markdown_path> --domain <domain> [options]
Options:
--domain, -d - Base domain (required)--subdomain, -s - Subdomain for the service--tenant, -t - Tenant ID (default: “default”)--tls/--no-tls - Enable TLS (default: enabled)--image - Container image for Markdown serverpactown quadlet generateGenerate Quadlet files without deploying.
pactown quadlet generate <markdown_path> [options]
Options:
--output, -o - Output directory (default: current)--domain, -d - Domain--subdomain, -s - Subdomain--tenant, -t - Tenant ID--tls/--no-tls - Enable TLS labelspactown quadlet shellStart interactive deployment shell.
pactown quadlet shell [--domain <domain>] [--tenant <tenant>]
pactown quadlet apiStart REST API server for programmatic deployments.
pactown quadlet api [--host <host>] [--port <port>] [--domain <domain>]
Access API docs at http://localhost:8800/docs
pactown quadlet listList all services for a tenant.
pactown quadlet list [--tenant <tenant>]
pactown quadlet logsShow logs for a service.
pactown quadlet logs <service_name> [--tenant <tenant>] [--lines <n>]
The interactive shell provides a REPL-style interface:
pactown quadlet shell --domain pactown.com
pactown-quadlet> status
pactown-quadlet> config tenant user01
pactown-quadlet> deploy ./README.md docs
pactown-quadlet> list
pactown-quadlet> logs my-service
pactown-quadlet> help
| Command | Description |
|---|---|
status |
Show configuration and status |
config <setting> <value> |
Configure settings |
generate <path> |
Generate Quadlet files |
generate_container <name> <image> <port> |
Generate custom container |
generate_traefik |
Generate Traefik files |
deploy <path> [subdomain] |
Deploy Markdown service |
undeploy <name> |
Remove a service |
start <name> |
Start a service |
stop <name> |
Stop a service |
restart <name> |
Restart a service |
list |
List all services |
logs <name> |
Show service logs |
reload |
Reload systemd daemon |
init |
Initialize environment |
export <dir> |
Export unit files |
Start the API server:
pactown quadlet api --port 8800 --domain pactown.com
POST /generate/markdown
Content-Type: application/json
{
"markdown_content": "# My Documentation\n\nContent here...",
"name": "my-docs",
"tenant_id": "user01",
"subdomain": "docs",
"domain": "pactown.com",
"tls_enabled": true
}
POST /deploy/markdown
Content-Type: application/json
{
"markdown_content": "# API Documentation\n\n...",
"subdomain": "api",
"domain": "pactown.com",
"tenant_id": "user01",
"tls_enabled": true
}
GET /services?tenant_id=user01
POST /services/{name}/start
POST /services/{name}/stop
POST /services/{name}/restart
GET /services/{name}/logs?lines=100
DELETE /deploy/{name}
~/.config/containers/systemd/
├── traefik.container # Traefik reverse proxy
├── traefik-letsencrypt.volume # Let's Encrypt certificates
├── tenant-user01/
│ ├── docs.container # User's docs service
│ ├── api.container # User's API service
│ └── content/
│ ├── docs.md # Markdown content
│ └── api.md
└── tenant-user02/
└── ...
[Unit]
Description=Pactown service: docs (tenant: user01)
After=network-online.target
[Container]
ContainerName=user01-docs
Image=ghcr.io/pactown/markdown-server:latest
Environment=MARKDOWN_TITLE=Documentation
Environment=PORT=8080
PublishPort=8080:8080
# Traefik routing
Label=traefik.enable=true
Label=traefik.http.routers.docs.rule=Host(`docs.pactown.com`)
Label=traefik.http.routers.docs.tls=true
Label=traefik.http.routers.docs.tls.certresolver=letsencrypt
# Resource limits
PodmanArgs=--cpus=0.5 --memory=256M
# Security
PodmanArgs=--security-opt=no-new-privileges:true
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
[Unit]
Description=Pactown pod: app
After=network-online.target
[Pod]
PodName=user01-app
PublishPort=8080:8080
PublishPort=8081:8081
Network=pactown-net
[Install]
WantedBy=default.target
[Unit]
Description=Pactown network
[Network]
NetworkName=pactown-net
Driver=bridge
Label=pactown.managed=true
[Install]
WantedBy=default.target
| Aspect | Podman Quadlet | Kubernetes (K3s) |
|---|---|---|
| Config files | 1-5 per user | 5+ per user + globals |
| Networking | Pod networks, Traefik | Ingress + Services |
| Certificates | Traefik Let’s Encrypt | cert-manager |
| Resource limits | systemd cgroups | ResourceQuota, HPA |
| Scaling | Single-node | Multi-node, auto-scaling |
| Security | Rootless, user namespaces | RBAC, NetworkPolicies |
| Overhead | Zero daemons | kubelet, etcd, API server |
| Best for | MVP, single VPS | Production, multi-node |
When you outgrow single-node deployment:
# Convert Quadlet containers to Kubernetes YAML
podman generate kube my-container > k8s-manifests.yaml
# Use with Helm for templating
helm template ./chart --set userId=user01 > user01-deploy.yaml
podman version
# Must be 4.4.0 or higher
sudo apt-get update
sudo apt-get install podman
sudo dnf install podman
# Check systemd status
systemctl --user status my-service.service
# View detailed logs
journalctl --user -u my-service.service -f
# Verify unit file syntax
podman-system-migrate
systemctl --user daemon-reload
# Enable lingering for user services
loginctl enable-linger $USER
# Verify user systemd is running
systemctl --user status
# Check Traefik logs
journalctl --user -u traefik.service -f
# Verify container labels
podman inspect my-container | grep -A20 Labels
--tls in production