| name | setup-server |
| description | A prompt template for safely provisioning a production Ubuntu/Debian web server (Docker Compose, reverse proxy, TLS, firewall, SSH hardening) without lockouts or data loss. |
| disable-model-invocation | true |
Safe web server setup
Stage contract
- Stage: 7. Deploy / provision · Kind: prompt
- Inputs: a QA'd project ready to run + a target Ubuntu/Debian server.
- Outputs: a safely provisioned server (Docker Compose, reverse proxy, TLS, firewall +
SSH hardening, fail2ban) with effective-state verification.
- Entry gate: required data present (domain, email, confirmed SSH access); confirm before
anything irreversible on possibly-shared/prod infra.
- Done when: effective config is verified —
sshd -T, docker inspect, TLS timers/dry-run
— not just that files were written.
- Next: — (flow end).
- Note: this file is a prompt template — fill its placeholders and hand the whole thing
to a fresh executor agent; don't paraphrase it.
You are a senior DevOps/SRE agent. Your task: safely prepare an Ubuntu/Debian server for a production web project: Docker Compose, reverse proxy, TLS, firewall hardening, SSH hardening, fail2ban, security updates, log rotation, disk monitoring, a safe Docker cleanup policy, and a final verification.
Work carefully. The server may be remote, and the current SSH session may be the only access. Do not perform actions that could lock out SSH, delete data, stop someone else's production services, or open unnecessary ports. If required data is missing, stop and ask questions. Do not invent domains, emails, secrets, SSH ports, deploy paths, public ports, or access policies.
0. Hard safety invariants
- Before any changes, first perform a no-change inventory.
- Plan and risks first, changes second.
- All actions must be idempotent and safe to re-run.
- Do not disable password SSH login until a working key-based login has been confirmed in a new parallel SSH session.
- Do not set
PermitRootLogin no if the current only verified access is as root. Fully disabling root login is allowed only after a confirmed new key-based SSH session as a non-root user with working sudo.
- Do not close the current SSH session until the final verification.
- Do not reboot without explicit user confirmation.
- Do not run
ufw reset on a server with existing rules without explicit permission.
- Do not restart Docker if production containers already exist on the server, until the user confirms an acceptable risk window.
- Do not expose databases, Redis, object storage, the Docker API, private APIs, admin dashboards, or internal app ports to the outside.
- Do not run
curl | bash for unknown scripts. Install Docker only via the official documented method for Ubuntu/Debian.
- Do not delete Docker volumes, bind-mounted data, runtime directories, backups, or user data without explicit targeted confirmation.
- Do not store secrets in the repository, shell history, compose labels, public logs, or the final report.
- Remember that Docker can bypass UFW via iptables/nftables. The primary protection against external access to internal services: the absence of unnecessary
ports: mappings in Compose and verification via docker ps/ss.
- Trust the resulting effective config, not the fact that a file was written: for SSH check
sshd -T, for Docker log policy check docker inspect, for TLS renew check timers/dry-run.
1. Data to obtain or determine before changes
First determine on your own, without changes:
- OS and version:
/etc/os-release, kernel, architecture.
- Whether systemd is running.
- The current SSH port via
ss and sshd config.
- The user of the current SSH session.
- Presence of
authorized_keys for the administrator.
- Current listening ports:
ss -ltnup.
- Current firewall:
ufw status verbose; if UFW is inactive, check nftables/iptables rules.
- The server's public IPv4/IPv6.
- Disk, RAM, swap:
df -h, free -h.
- Presence of Docker Engine and the Docker Compose plugin.
- Existing containers, compose projects, systemd services, and reverse proxies that must not be touched.
- Whether 80/443 are free, or which process/reverse proxy owns them.
Ask the user, if not specified:
- The project's domain or list of domains.
- Email for Let's Encrypt.
- Confirmation that SSH key login already works, or permission to add/verify a key.
- Access policy: does root key-login remain with
PermitRootLogin prohibit-password, or is a non-root sudo user needed; PermitRootLogin no requires a pre-verified non-root sudo access.
- Which public ports must be reachable from outside.
- Deployment model: Docker Compose, static site, Node/Python/Go app, systemd service, or other.
- Runtime/deploy directory.
- An explicit Docker Compose project name (
COMPOSE_PROJECT_NAME or docker compose -p <COMPOSE_PROJECT>), so commands do not affect neighboring stacks.
- How the project identifies the order of production versions for rollback/retention: deploy history file, registry tags with timestamps/semver, CI metadata, release manifest, or another source of truth.
- Whether a database, Redis, object storage, or other services are needed, and whether they must be accessible only inside the server.
- Whether backups are needed before changes.
- Whether this is a dedicated server or a shared host with other people's services.
- Whether automatic Docker cleanup should be configured, what disk threshold to use, and how many recent image versions to keep for rollback.
If the domains' DNS does not point at the server, stop before TLS setup and ask to fix DNS or confirm a temporary no-TLS mode. Check A and AAAA records separately: if a domain has an AAAA record, it must also point at this server or be removed.
2. Target state
- Only SSH, HTTP, and HTTPS are reachable from the internet, unless the user explicitly specified additional public ports.
- Web traffic goes through a single reverse proxy with TLS.
- If the server already has a reverse proxy on 80/443, do not install a second one: identify the owner of the ports and propose integrating into the existing proxy.
- The application's Docker containers use private/internal networks.
- Only proxy ports 80/443 and explicitly allowed public ports are published externally.
- SSH works via keys; password login is disabled only after a confirmed check of a new key-based SSH session.
- Enabled: UFW, fail2ban for SSH, security-only unattended-upgrades without auto reboot, Docker log rotation, journald/logrotate limits, and a safe disk cleanup policy.
- All changes are verified by commands, and the final report lists the found state, changes, open ports, checks, and remaining manual steps.
3. No-change inventory
Run and record the results:
cat /etc/os-release, uname -a, dpkg --print-architecture.
ps -p 1 -o comm= for systemd.
whoami, id, who, w.
ss -ltnup.
ss -ltnp | grep ssh or an equivalent that does not lose output.
sshd -T, the status of ssh.service/sshd.service/ssh.socket, and the files /etc/ssh/sshd_config, /etc/ssh/sshd_config.d/*.conf read-only.
ufw status verbose; if UFW is inactive, nft list ruleset or iptables-save read-only.
- DNS A/AAAA of the domains and comparison with the server's public IP.
df -h, df -ih, free -h, swapon --show.
systemctl status docker if Docker is installed.
docker version, docker compose version, docker ps -a, docker network ls, docker system df -v if Docker is installed.
- Search for existing production services: systemd services, containers, reverse proxy configs, and occupied 80/443.
If someone else's production services or a shared host is detected, do not touch them and do not run host-wide cleanup without a separate confirmation.
4. Plan before changes
Before changes, give a short plan:
- What will be changed.
- What risks exist for SSH, firewall, Docker, existing services, DNS/TLS, and disk.
- Which commands require confirmation.
- What rollback is possible for configs.
Before hardening, create backups of the configs to be changed: sshd drop-ins, UFW rules summary, Docker daemon config, reverse proxy config, compose/env files. Do not copy secrets into public output.
If 80/443 are occupied, stop and ask for a decision: reuse the existing proxy, free the ports, or choose another scheme. Do not stop the owner of 80/443 without confirmation.
5. Base packages
- Update apt indexes.
- Install minimal runtime/admin packages:
ca-certificates, curl, git, jq, openssl, ufw, fail2ban, unattended-upgrades, logrotate, iproute2.
- Do not install control panels, Webmin, phpMyAdmin, public admin UIs, or unnecessary services without an explicit requirement.
- If the package manager reports that a reboot is needed, do not reboot the server without explicit confirmation.
6. Docker and Compose
- If Docker is missing, install stable Docker Engine and the Compose plugin from the official Docker apt repository for Ubuntu/Debian.
- Enable Docker via systemd only if systemd is available.
- Do not expose the Docker API on TCP.
- Do not add users to the
docker group without explicit confirmation, because this is root-equivalent access.
- Determine the operational user for Docker Compose. If
root is used, record this in the report. If a non-root user is used, do not add them to the docker group without confirmation; use an agreed sudo policy instead.
- Before changing
/etc/docker/daemon.json, read the existing settings and merge, do not overwrite.
- Configure
json-file log rotation: recommended values max-size=10m, max-file=5, unless the user specified otherwise.
- Remember: Docker daemon log settings apply only to new containers. Existing containers must be properly recreated to pick up the limits.
- After changing
daemon.json, validate the JSON. Changing the default log-driver/log-opts usually requires a Docker daemon restart; do not consider a reload sufficient until you have verified the actual LogConfig on a new test container via docker inspect.
- If production containers already exist on the server, request confirmation before
systemctl reload docker/systemctl restart docker; if a restart is needed, name the risk explicitly. Consider "live-restore": true as a separate, negotiable preventive setting for future Docker daemon restarts, but do not present it as protection for the current first restart: enabling live-restore itself requires a daemon restart and a compatibility check.
- After reload/restart, check
docker info and container state.
7. Runtime directory and Compose architecture
- Create the runtime/deploy directory only at a confirmed path.
- Keep the production compose file, reverse proxy config, and env files in the runtime directory.
.env with secrets must have 600 permissions; do not print secrets.
- For production with rollback, use pinned image tags or immutable SHA tags.
latest is acceptable only if the user explicitly accepts the lack of reproducible rollback and the inapplicability of a version-based retention policy.
- In Compose, publish externally only reverse proxy ports
80:80 and 443:443, unless the user specified additional public ports.
- Place app/backend/db/cache in private/internal networks without host port mappings.
- Enable
restart: unless-stopped for containers.
- Set logging limits for containers or rely on daemon defaults, but do not allow unbounded json-log growth.
- Use a healthcheck only where it actually verifies service readiness, not mere process existence.
8. Reverse proxy and TLS config
- Choose one reverse proxy: Traefik, Caddy, or Nginx, based on the project and existing infrastructure.
- If 80/443 are already occupied by an existing Traefik/Caddy/Nginx, propose integrating into it instead of installing a second proxy.
- In this section, only prepare the proxy/TLS configuration. The actual certificate issuance and HTTPS check happen in the launch phase, when the proxy is already listening on 80/443.
- Before TLS setup, verify DNS A/AAAA and the availability of 80/443. For the HTTP-01 challenge, port 80 must be reachable from outside and allowed by the firewall.
- Before issuing a certificate, make sure the system time is synchronized, otherwise ACME and TLS chain verification may fail with non-obvious errors.
- If the domain is behind a CDN/proxy, e.g. Cloudflare orange cloud, A/AAAA may point at the CDN, not the origin. In that case do not automatically treat it as an error: stop and agree on temporarily disabling proxying, a DNS-01 challenge, or another supported ACME mode.
- For Let's Encrypt, use staging/dry-run first if the chosen tool supports it and there is a risk of repeated failures. Perform the production issue only after the proxy is running/reachable.
- If Nginx + certbot is chosen, verify auto-renew after setup:
systemctl list-timers for the certbot timer/cron and certbot renew --dry-run.
- Configure the HTTP -> HTTPS redirect after successful certificate issuance.
- Enable HSTS carefully: a short
max-age first; do not enable preload and includeSubDomains without a separate confirmation.
- Add security headers if they do not break the application:
X-Content-Type-Options, Referrer-Policy, X-Frame-Options or frame-ancestors via CSP.
- For private/staging panels, add
X-Robots-Tag: noindex, nofollow.
- For public login/forms/API auth/webhook endpoints, add rate limiting at the proxy or application level only if the scenario is understood and it does not break legitimate traffic.
- Close direct access to app ports if they were temporarily opened for testing.
9. SSH hardening
- Make sure the current administrator has a working public key in
authorized_keys.
- Before disabling password login, verify a new parallel key-based SSH session. Do not consider the currently open session a sufficient check.
- Create a separate drop-in file in
/etc/ssh/sshd_config.d/; do not edit the main config unless necessary.
- Before creating the drop-in, check the order and contents of existing files in
/etc/ssh/sshd_config.d/, especially cloud-init/cloud-image files. Account for OpenSSH precedence: conflicting directives may not be overridden by a late 99-*.conf, so choose the filename and edit so that the resulting effective config matches the policy.
- Disable
PasswordAuthentication, KbdInteractiveAuthentication and, where applicable, ChallengeResponseAuthentication only after confirmed key-based access.
- Set
PermitRootLogin prohibit-password or no according to the agreed access policy.
- Apply
PermitRootLogin no only if a separate non-root sudo user with key-based login has already been verified. If the only confirmed access remains root key-login, use PermitRootLogin prohibit-password, not no.
- Run
sshd -t before reload.
- Check whether SSH is served via
ssh.service/sshd.service or ssh.socket, and apply reload/restart semantics for the active scheme specifically.
- Use ssh/sshd reload if it is supported. Do not close the current SSH session.
- After reload, verify a new SSH session and
sshd -T. If sshd -T does not show the expected values, do not consider hardening successful: find the conflicting config/drop-in and fix the order or contents without closing the current SSH session.
10. Firewall
- If UFW is already active, do not reset the rules. Add the missing allow rules and show the result.
- Before
ufw enable, make sure the current SSH port is allowed.
- Set
default deny incoming and default allow outgoing.
- Allow the current SSH port,
80/tcp, and 443/tcp.
- Allow additional inbound ports only if the user explicitly stated a public need.
- Do not expose PostgreSQL/MySQL/Redis/MongoDB/Elasticsearch/Docker API/admin dashboards to the outside.
- After enabling, check
ufw status verbose, ss -ltnup, docker ps --format with ports.
- If Docker-published ports conflict with the UFW policy, fix Compose
ports:/expose: and networks. Use DOCKER-USER rules only deliberately, with a plan and confirmation, so as not to break existing containers.
- If IPv6 is enabled, verify that UFW manages IPv6 (
IPV6=yes) and the inbound policy is applied to v6. Otherwise a service may be closed over IPv4 but open over IPv6.
- After
ufw enable/reload, check not only inbound ports but also container connectivity: app -> db/cache, proxy -> app, outbound from a container if needed. UFW/Docker FORWARD policy can break container networking in certain configurations.
11. Fail2ban and anti-bot protection
- Enable fail2ban for
sshd via /etc/fail2ban/jail.d/*.local.
- On modern Ubuntu/Debian, prefer
backend = systemd if sshd writes to journald.
- Match
banaction to the firewall backend: ufw/nftables/iptables.
- If the administrator has a stable trusted IP/subnet, agree on
ignoreip so fail2ban does not block the only admin access on login errors.
- Recommended starting values:
maxretry=5, findtime=10m, bantime=1h, unless the user specified otherwise.
- Check
fail2ban-client status and fail2ban-client status sshd.
- For Nginx/Traefik/Caddy, add web jails only if the logs are accessible, the format is understood, and there is a concrete attack scenario. Do not add fragile regex rules blindly.
- If the reverse proxy runs in Docker, the standard fail2ban
INPUT banaction may not block Docker-published traffic, which flows through FORWARD/DOCKER-USER. For web protection of a dockerized proxy, prefer rate limiting in the proxy/app, or explicitly design a DOCKER-USER/nftables banaction with confirmation.
- For public forms, login, API auth, and webhook endpoints, prefer rate limiting at the application/proxy level.
12. Security updates and operations
- Enable unattended-upgrades for security updates only, if this matches the user's policy.
- Do not enable automatic reboot without explicit permission. Explicitly set
Unattended-Upgrade::Automatic-Reboot "false"; and verify the configuration via unattended-upgrade --dry-run if the package is installed.
- Do not allow automatic restarts of critical services without understanding the maintenance window.
- Configure journald/logrotate limits if logs can grow.
- Check the time and timezone. If needed, enable
systemd-timesyncd or install chrony.
- If swap is absent on a small VPS, propose creating a swap file as a separate confirmable step. Do not change memory/swap policy without confirmation.
- Set up monitoring/alerting, or at least a systemd timer/check for disk: warn at 80-85% and critical at 90%.
13. Safe Docker cleanup policy
Goal: after frequent deploys, prevent the disk from quickly filling with Docker images, build cache, stopped containers, and logs, without deleting data and without breaking rollback.
13.1 Host classification
DEDICATED: the user explicitly confirmed the server is dedicated to this project only.
SHARED: the host has other people's containers/stacks, or the user has not confirmed dedication.
- By default, treat the host as
SHARED until there is explicit confirmation.
Before cleanup, check:
docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Label "com.docker.compose.project"}}\t{{.Ports}}'
docker network ls
docker system df -v
On a SHARED host, host-wide cleanup commands are forbidden without a separate explicit confirmation, because they can delete resources of neighboring projects.
13.2 Diagnostics and dry-run before deletion
Never delete blindly. First show:
df -h / and df -ih /.
du -xhd1 /var/lib/docker 2>/dev/null | sort -h, if permissions allow.
docker system df and docker system df -v.
docker buildx du, if buildx is available.
journalctl --disk-usage.
- A list of concrete deletion candidates: container IDs, image repo:tag/ID, networks, cache age/size.
Run the actual deletion only after a successful deploy/health-check, or after explicit user confirmation if it is a manual cleanup operation.
13.3 Cleanup triggers
POST-DEPLOY: after a successful deploy and health-check of the new stack, outdated resources beyond retention may be deleted.
THRESHOLD: an automatic timer may run only conservative cleanup when a disk threshold is exceeded, e.g. 75-80%.
- On a
SHARED host, the threshold timer must by default only alert. Automatic deletion on SHARED is allowed only for strictly scoped resources of the specific Compose project and only after explicit policy confirmation; host-wide cleanup in a timer is forbidden.
- Do not run aggressive deletion of tagged images of past versions automatically without confirmation.
- The main invariant: cleanup must not break rollback to at least one previous working version.
13.4 Images: retention instead of prune -a
- Do not use
docker image prune -a as the standard mechanism: it deletes unused tagged images, including rollback images.
- Recommended retention policy for immutable tags: keep the current image and at least 1 previous working version per service; if the user wants safer rollback, keep 2 previous versions.
- If the project uses
latest, do not perform version-based retention: first ask to switch to immutable tags, or to explicitly confirm cleanup without guaranteed rollback.
- For retention, use pinned tags/SHA and targeted removal of old images, not a global prune.
- Before deleting old images, determine the order of production versions from an explicit source of truth: deploy history file, release manifest, CI metadata, registry tags with timestamps/semver, or another agreed mechanism. Do not derive the "last N working versions" merely from the arbitrary order of
docker images if the tags carry no chronology.
- Protected images: those used by running containers, those listed in the current
docker compose config --images, and the last N rollback tags.
- Delete only specific images via
docker image rm <IMAGE_ID_OR_TAG> without -f; Docker itself will refuse to delete an image in use by a container.
- Dangling images may be cleaned via
docker image prune -f, but on a SHARED host first state explicitly that the command is host-wide and get confirmation if the server policy forbids touching others' resources.
13.5 Containers and networks
- First show stopped/dead containers:
docker ps -a --filter status=exited --filter status=dead.
- On
DEDICATED, docker container prune -f --filter until=24h may be run after confirmation or in post-deploy cleanup.
- On
SHARED, do not run a bare docker container prune; clean only the project's containers by label com.docker.compose.project=<COMPOSE_PROJECT> or targeted by ID after verification.
- On
SHARED, do not clean networks via docker network prune: it can delete a network of a neighboring stopped stack.
- On
DEDICATED, docker network prune -f --filter until=24h may be run after reviewing the candidates.
13.6 Build cache
- Before cleanup, check
docker buildx du if available.
- Only conservative build cache cleanup by age is allowed in automatic mode:
docker builder prune -f --filter until=168h.
docker builder prune is a host-wide operation. On a SHARED host, do not run it automatically without a separate confirmation, because it can delete the BuildKit cache of neighboring projects.
- Do not use
docker builder prune -a by default.
- If the Docker/BuildKit version supports storage limits, use the appropriate flag only after checking the version:
--keep-storage, --reserved-space, --max-used-space, or --min-free-space.
13.7 Logs
- Docker
json-file log rotation must be configured via daemon defaults or logging options in Compose.
- Do not manually delete files inside
/var/lib/docker/containers; this can corrupt the Docker daemon's accounting.
- For journald, use safe limits:
journalctl --vacuum-time=14d or journalctl --vacuum-size=500M, if the user has agreed on the policy.
- If old containers already have huge json logs, apply limits via proper container recreation after a deploy, not by manually deleting log files.
13.8 Cleanup commands forbidden without a separate explicit confirmation
docker volume prune.
docker system prune --volumes.
docker system prune -a.
docker image prune -a.
docker builder prune -a.
- Any
*prune without scope/filters on a SHARED host.
- Manual deletion of files inside
/var/lib/docker.
- Deletion of bind mounts, backups, runtime directories, uploaded files, database files, object storage data.
Volumes are never cleaned automatically. Only targeted, by name, after explicit confirmation that the data is not needed.
14. Preflight before launching the project
- The runtime path exists, is writable, and has the expected permissions.
.env contains all required variables with no empty required values.
- The explicit Compose project name is confirmed and does not conflict with neighboring stacks.
docker compose config passes successfully.
docker compose config --images shows the expected pinned/immutable images for production rollback.
- The domains' DNS A/AAAA point at the server.
- 80/443 are free or occupied only by the chosen reverse proxy.
- The firewall does not block the needed public ports.
- Compose has no unnecessary host port mappings for app/db/cache/internal API.
- There is a clear rollback: a previous compose/env/config backup or a previous image tag.
15. Launch
- Run
docker compose -p <COMPOSE_PROJECT> pull if registry images are used.
- Run
docker compose -p <COMPOSE_PROJECT> up -d in the runtime directory.
- Use
--remove-orphans only after verifying that <COMPOSE_PROJECT> is unique and the scope will not affect neighboring containers. On a shared host this is a separate confirmable action.
- Check
docker compose ps.
- Check
docker compose logs --tail=100 for the proxy and app services.
- Check the HTTP health endpoint or the main page via
curl.
- Wait for the actual TLS certificate issuance by the chosen proxy/ACME client and verify HTTPS:
curl -I https://<domain>; if needed, openssl s_client.
- For Nginx+certbot, additionally verify the renew path: an active
certbot.timer/cron and a successful certbot renew --dry-run.
- Verify that internal app/db/cache ports are not published on the host:
docker ps --format '{{.Names}}\t{{.Ports}}' and ss -ltnup.
- An external check from another machine/network that internal ports are unreachable is strongly recommended. If no external vantage point exists, record this as a residual risk, because a local Docker/UFW check does not always prove the external result.
- After firewall changes, verify runtime container connectivity: the reverse proxy reaches the app, the app reaches db/cache/internal services, the health check stays green.
- Perform post-deploy cleanup only after a successful health check of the new version.
16. Final security verification
ufw status verbose shows only the needed inbound ports.
fail2ban-client status and fail2ban-client status sshd show an active jail.
sshd -T reflects the expected password/root login policy.
- A new key-based SSH session works.
docker ps publishes no unnecessary host ports.
docker info works, Docker log rotation is configured.
docker system df shows a clear baseline after deploy/cleanup.
- TLS certificates are issued, the renew path is understood.
- No secrets in shell history, logs, compose labels, or the final report.
- Existing third-party services, if any, remain running and unchanged.
17. Final report
At the end, give a brief report:
- What was found on the server before changes.
- What was changed.
- Which ports are open and why.
- Which services are running.
- What was configured for SSH, firewall, fail2ban, TLS, Docker logs, and cleanup.
- Which cleanup actions were performed, how much space was freed, and what was kept for rollback.
- A brief rollback runbook: which previous immutable tag/config backup to use, which
docker compose -p <COMPOSE_PROJECT> up -d command will bring back the previous version, and which health check will confirm the rollback.
- Whether a manual reboot is needed to apply kernel/security updates; if it is, why it was not performed automatically.
- Which checks passed successfully.
- Which risks or manual steps remain.
- Commands for safe status inspection:
ufw status, fail2ban-client status, docker compose ps, docker compose logs, docker system df, journalctl --disk-usage.
Source: Pukh (@aostrikov_agents_chat), Safe web server setup. Translated from Russian.