Skip to content

Deploy to a server you already have

OpsCrew can deploy your app to a server you already pay for or run yourself: a rented server at Contabo, OVH, Hetzner Robot, Vultr or Linode, a machine at work, or a mini PC or Raspberry Pi at home. You get the same result as on the servers OpsCrew creates for you: a hardened server, the app deployed from GitHub with TLS, health checks and rollback, and the verify, secure and drift checks.

OpsCrew never creates, reinstalls or deletes your server. It inspects it first, shows you every change it would make, and changes nothing until you approve that exact list.

  • A server with Ubuntu 22.04, 24.04 or 26.04 LTS, or Debian 12 or 13, on amd64 (x86-64) or arm64 (for example a Raspberry Pi with the 64-bit OS).
  • SSH access with a key, as root or as a user that can run sudo without a password. OpsCrew never asks for a password or a passphrase: give it the path to your key file, or let it use your SSH agent (which also covers a key with a passphrase).
  • Room for the app: one server can hold several OpsCrew apps, each with its own components and folders (see Several apps on one server).
  • On your computer: Node.js 22.9 or newer, git, OpenTofu or Terraform, and a GitHub repository with your app, as for every OpsCrew deployment.
  • A domain for your site. Its DNS can be anywhere when your server serves the site directly with DNS only (see How visitors reach your server). A domain on Cloudflare and a Cloudflare API token (CLOUDFLARE_API_TOKEN) are needed only for the Cloudflare Tunnel, for serving behind Cloudflare’s proxy and for a Portainer subdomain. A server that the internet cannot reach (see Home servers) always uses the tunnel, so it always needs them.

If your sudo user needs a password, OpsCrew stops and tells you how to allow passwordless sudo for that user (a line in /etc/sudoers.d/), or connect as root instead.

  1. npx opscrew interview: choose A server you already have. OpsCrew then asks for the server’s address (IP address or host name), the SSH port this computer and GitHub Actions connect to (usually 22; when a router forwards another port to the server, the forwarded one), the SSH user, which key to use (a path such as ~/.ssh/id_ed25519, or agent), whether the internet can reach the server and how your site is served (see How visitors reach your server). In scripts, put the same answers in a file and pass --answers <file>:

    provider: own
    server.host: 203.0.113.20
    server.port: 22
    server.user: root
    server.key: ~/.ssh/id_ed25519
    server.reachable: true
    server.serve: tunnel # tunnel, proxy (behind Cloudflare's proxy) or dns-only (directly)
  2. npx opscrew design: OpsCrew connects to the server for the first time and shows its SSH host key fingerprint. Check it against the server itself, for example in your provider’s web console: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub. Approve only if they match. OpsCrew then reads the server’s size, memory, disk and architecture (it changes nothing) and offers it as the one option. There is no server price, because you already pay for it. OpsCrew warns you when the server has less memory or free disk than the app needs, counting the memory limits of the other OpsCrew apps already on it, and stops when the app’s components do not fit at all.

  3. npx opscrew adapt prepares the app for a container, as for any other deployment. Merge its pull request.

  4. npx opscrew provision shows up to three plans and asks for each separately:

    • the Cloudflare plan, when your domain is on Cloudflare: your app’s hostnames in the rules OpsCrew keeps in your zone for all its apps (only the probe block in tunnel mode), and in tunnel mode the account’s Zero Trust organization (see Your domain on Cloudflare), with its hash;
    • the infrastructure plan: the DNS records on Cloudflare, and the Cloudflare Tunnel with its access rules, when your site uses it, with its hash (with DNS somewhere else and DNS-only serving, you add the record yourself);
    • the host plan: every change OpsCrew would make on your server, each marked as already in place or to be changed, with its hash.

    It then stores the deploy secrets in GitHub and opens a pull request with the deploy workflow. Merging it runs the first deploy.

  5. npx opscrew verify checks the live site and writes opscrew/RUNBOOK.md; npx opscrew secure checks the server’s hardening.

Running npx opscrew provision again later shows what is still missing; on a prepared server the plans show no changes and nothing on the server changes.

Without a terminal, nothing is approved for you:

Terminal window
npx opscrew design --host-key SHA256:... # the fingerprint you checked
npx opscrew provision --plan-only --yes # prints every hash and the command
npx opscrew provision --approve <Cloudflare plan hash> --approve <infrastructure hash> --approve <host plan hash> --yes

Each --approve names exactly one plan you reviewed. --yes only approves writing the files, storing the GitHub secrets, pushing and opening the pull request; it never approves a plan.

With the Cloudflare Tunnel, the host plan needs the tunnel’s id, so its hash appears only after the infrastructure plan is applied. The first --plan-only prints only the Cloudflare plan’s and the infrastructure plan’s hashes. Apply those first, then plan again and approve the host plan in a second run. The first applying run ends with an error that asks for the host plan’s approval (and prints its hash), so a script that stops on errors lets that one through:

Terminal window
npx opscrew provision --plan-only --yes # prints the Cloudflare and infrastructure hashes
npx opscrew provision --approve <Cloudflare plan hash> --approve <infrastructure hash> --yes || true # applies them, then asks for the host plan
npx opscrew provision --plan-only --yes # now prints the host plan's hash
npx opscrew provision --approve <host plan hash> --yes

A switch between serving directly and the tunnel (after you change server.serve and run design again) first removes the old DNS records, so --plan-only prints a command that also needs the app’s name. Run it, then plan and apply the new setup:

Terminal window
npx opscrew provision --plan-only --yes # prints the removal command
npx opscrew provision --approve <removal hash> --confirm <app> --yes
npx opscrew provision --plan-only --yes # prints the new setup's hashes
npx opscrew provision --approve <infrastructure hash> --approve <host plan hash> --yes

When the server changes too, the printed removal command also has --approve <host plan hash>; keep it as printed. Without --confirm a script never removes anything.

The host plan lists these steps, and only the ones that are not in place yet are changed:

  • Docker Engine 28 or newer and the Compose plugin from Docker’s own repository (an older Docker from that repository is upgraded). Docker 28 keeps ports that are published only on the server itself really local.
  • Docker settings in /etc/docker/daemon.json: log rotation, no-new-privileges, live-restore. When the server already runs containers OpsCrew did not create, OpsCrew leaves Docker’s daemon settings alone, so your containers keep behaving as before; OpsCrew’s own services set their log rotation and no-new-privileges themselves.
  • A deploy user with the project’s deploy key and access to Docker. GitHub Actions deploys as this user. It has no password.
  • SSH hardening in /etc/ssh/sshd_config.d/10-opscrew.conf, checked with sshd -t before SSH reloads:
    • password logins are turned off, unless other users on the server still log in with a password and have no key; then OpsCrew asks you to choose: --password-logins off turns them off anyway, --password-logins keep leaves them on;
    • if you connect as root, root keeps logging in with its key only (so you are never locked out); otherwise root logins are turned off. The stricter option is a sudo user and no root login;
    • only modern key exchanges, ciphers, MACs and host keys are offered, as on the servers OpsCrew creates (see deeper checks).
  • The ufw firewall: incoming traffic is denied except SSH on the port your server really uses (never port 22 by assumption) and, when the server serves the web itself instead of through the tunnel, HTTP and HTTPS. When ufw is already on but lets everything in, OpsCrew sets it to deny incoming traffic except SSH, web traffic (when the server serves the web) and what your own ufw rules already allow; if a service other machines use has no rule, OpsCrew stops and names it. Undoing OpsCrew’s host changes puts the old default back. OpsCrew does not add a second firewall when your server already uses firewalld or its own nftables rules; it stops and explains instead.
  • The Cloudflare-only filter, only when you chose to serve directly behind Cloudflare’s proxy (see How visitors reach your server).
  • fail2ban for SSH, and automatic security updates.
  • The shared folders /srv/edge (the edge every app sits behind), /srv/apps and /srv/data, and the app’s own folders: /srv/apps/<app> for its Compose project and settings, and /srv/data/<app> for its data.
  • The app’s record in /etc/opscrew/apps/<app>.json: its hostnames, folders and memory limits, which the next app’s checks read.
  • The kernel setting vm.max_map_count in /etc/sysctl.d/90-opscrew.conf, only when the app runs OpenSearch or Elasticsearch (see Search and analytics engines).

The host plan says which steps are shared with the other apps on the server and which are this app’s own. While other apps are there, a shared part that is in place stays as it is.

SSH and firewall changes run under a safety switch: before the change, the server schedules its own undo; afterwards OpsCrew opens new SSH connections as deploy and as your own user, and only when both work does it cancel the undo. If they do not work, the server puts the previous settings back by itself, even if OpsCrew lost its connection.

OpsCrew changes nothing it did not list, never removes software, users, containers or files it did not install, and records every change (and the value it replaced) in /etc/opscrew/host.json on the server. If the connection drops while it works, the next npx opscrew provision shows only what is still missing.

When OpsCrew stops without changing anything

Section titled “When OpsCrew stops without changing anything”

It names what it found and what you can do when:

  • another web server or app already listens on ports 80 or 443 (free the ports, or use a Cloudflare Tunnel);
  • another firewall manages the server (firewalld, or nftables rules of its own);
  • Docker comes from Snap, from your distribution’s package (docker.io) or is Podman;
  • the operating system is not one of the supported releases;
  • your user needs a password for sudo;
  • another OpsCrew app on the server already uses the app’s name or one of its hostnames, or the apps would reach visitors in different ways (see Several apps on one server);
  • the app runs MongoDB and the server’s x86 CPU lacks AVX, which MongoDB needs.

You can deploy several OpsCrew apps, from different repositories, to the same server. Each is set up by its own npx opscrew provision, and they share only what must be shared:

  • Each app has its own Docker Compose project, named after the app, in /srv/apps/<app>, its data in /srv/data/<app>, its deploy key, its record in /etc/opscrew/apps/<app>.json, its components with their memory limits, and, with the Cloudflare Tunnel, its own tunnel and tunnel connector (the Compose project <app>-tunnel).
  • The edge is shared: the server’s proxy (Caddy, or nginx with certbot; see the reverse proxy) and kamal-proxy in /srv/edge (the Compose project opscrew-edge) serve every app, each on its own hostnames, with one site file per app in /srv/edge/sites/. Each deploy brings the edge’s files along and keeps the newer version, so a deploy never downgrades it, and only a changed edge is restarted.
  • The server’s setup is shared: Docker, the deploy user, SSH, the firewall, fail2ban and automatic updates. A later app’s host plan finds them in place and changes nothing there.

A deploy or a change of one app never touches another app’s containers or files, and npx opscrew drift and npx opscrew status look only at the app of the repository you run them in.

Before any change, OpsCrew refuses an app when:

  • another app on the server uses its name (or the name opscrew-edge, which the edge keeps);
  • another app already serves one of its hostnames;
  • one of them has no domain (each app needs its own hostnames on a shared edge);
  • the apps would reach visitors in different ways: all apps on a server use the same one of the three ways below (the tunnel, behind Cloudflare’s proxy with the filter, or DNS only);
  • the app asks for a different proxy (target.edge) than the server runs. A server’s proxy can be switched while it holds only one OpsCrew app.

Design checks the memory: the server’s memory, minus the memory limits of the apps already there and their tunnel connectors, OpsCrew’s edge (128 MB) and the system (384 MB), must hold the new app’s components. It warns when less than 15% stays free.

When you remove an app, the others keep running. The edge goes with the last app.

An app’s environments can share your server: npx opscrew env add staging offers prod’s server by default. Staging then runs beside prod exactly as a second app does, under its own name <app>-staging (/srv/apps/shop-staging, /srv/data/shop-staging, its own record, deploy key and hostname such as staging.example.com), behind the same edge. Its memory limits count in design’s check like any other app’s, and there is no extra server cost.

Each environment has its own host plan, approved by its own hash; the server’s shared setup is already in place, so a second environment’s plan changes only what is its own: its folders, its record and its deploy key. npx opscrew destroy --env staging removes only staging, and prod keeps running. An environment can also go to another server of yours: answer its address when env add asks.

With the server’s runner, the deploy job runs on your server itself, so GitHub no longer reaches it over SSH.

The interview asks how your site is served. There are three ways:

  1. Through a Cloudflare Tunnel (the default whenever your site is behind Cloudflare’s proxy, also for a server with a public address, and the only way for a server the internet cannot reach). The server connects out to Cloudflare and opens no web port at all, so nobody can reach the app around Cloudflare. It needs Cloudflare Zero Trust turned on (free); see Cloudflare Tunnel below. Pick this unless you have a reason not to.
  2. Directly behind Cloudflare’s proxy, with a Cloudflare-only filter (an explicit choice for a server with a public address and a domain on Cloudflare). Your domain points at the server through Cloudflare, and a filter lets only Cloudflare’s addresses reach ports 80 and 443. The filter sits in Docker’s own DOCKER-USER chain, because ports that Docker publishes bypass ufw and ufw rules could not limit them. It fails closed: if the filter cannot be installed when the server starts, Docker does not start either, so the site goes down instead of being open to everyone. On top of the filter, origin protection makes the server accept the app’s hostnames only from Cloudflare acting for your own zone. Pick this when you cannot or do not want to use Cloudflare Zero Trust. When Cloudflare changes its address ranges, the next npx opscrew provision shows the update as a host plan change and npx opscrew drift reports it.
  3. Directly, DNS only (no Cloudflare proxy, or your DNS somewhere else). The server answers everyone on ports 80 and 443, as any web server does, with no Cloudflare protection in front. Pick this only when you do not use Cloudflare. With another DNS provider you add the record yourself.

When the internet cannot reach your server (at home, behind a router), or when you choose the tunnel for a server with a public address, OpsCrew serves the app through a Cloudflare Tunnel: the server connects out to Cloudflare, and no inbound web port is opened. The firewall then allows only SSH.

  • You need a domain whose DNS is on your Cloudflare account.
  • Cloudflare Zero Trust must be turned on for your account: open one.dash.cloudflare.com, pick a team name and the Free plan. OpsCrew tells you when it is not on yet, and changes nothing until it is.
  • The server must be allowed to connect out to Cloudflare on port 7844.
  • GitHub Actions still deploys after every merge: it reaches your server’s SSH through a separate hostname (for the app shop on example.com: ssh-shop.example.com) that Cloudflare Access protects with a service token only your repository’s deploy secrets hold. GitHub Actions gets only that hostname, never your server’s address.
  • The tunnel’s own token (the one its connector on your server uses) stays only on your server and in OpsCrew’s local state on your computer. Only the Access service token is in your repository’s deploy secrets.
  • While you run npx opscrew provision, your computer must reach the server directly (on the same network, or through your VPN).

Switching an existing deployment between serving directly and the tunnel runs as two applies: OpsCrew first removes the old DNS records (you confirm with the app’s name), then adds the new ones. For the commands in a script, see In scripts.

Your CLOUDFLARE_API_TOKEN needs Zone → Zone → Read, Zone → DNS → Edit, Zone → Config Rules → Edit, Zone → Zone WAF → Edit, Zone → SSL and Certificates → Edit and Zone → Zone Settings → Edit, as for every OpsCrew deployment (the rules OpsCrew keeps in your zone, see Your domain on Cloudflare, then origin protection behind Cloudflare’s proxy and the minimum TLS version). Tunnel mode (and a Portainer subdomain) also needs:

  • Account → Cloudflare Tunnel → Edit
  • Account → Access: Apps and Policies → Edit
  • Account → Access: Service Tokens → Edit
  • Account → Access: Organizations, Identity Providers, and Groups → Edit

The first time OpsCrew connects, you confirm the server’s SSH host key fingerprint (or pass it with --host-key in scripts). OpsCrew pins it for this project. If the server ever presents a different key, every command stops with a warning and changes nothing: someone may be intercepting the connection. If you reinstalled the server or replaced its keys yourself, check the new fingerprint on the server and run the command again with --host-key and that fingerprint.

The address, SSH port, user, key path and the pinned host key are kept on your computer only, in .opscrew/server.json (which opscrew init --fix adds to .gitignore). They never go into opscrew/plan.yaml, other files in your repository, pull requests or commit messages. GitHub Actions gets the address it deploys to as an encrypted deploy secret; with the Cloudflare Tunnel it gets only the SSH hostname behind Cloudflare Access instead, never the server’s address. If .opscrew/server.json is damaged, fix it rather than deleting it: it holds the host key you confirmed.

  • npx opscrew status lists the app’s components and whether each runs, the server’s proxy, each certificate’s expiry and the state of the recovery copy.
  • npx opscrew drift compares the server with what OpsCrew set up (SSH settings, firewall rules, the deploy user, runtime files and the app’s components) and Cloudflare with the plan, and reports every difference without changing anything.
  • npx opscrew secure checks the server’s hardening and probes it from outside; --deep adds Lynis, ssh-audit and testssl.sh.
  • npx opscrew verify checks the live site.

OpsCrew cannot turn on backups or snapshots on a server it did not create. Back up the app’s data yourself, in particular /srv/data/<app> (where its databases and other services keep their files) and the app’s Docker volumes, for example with your provider’s backup feature or a nightly copy to another machine. Databases shows how to dump a database.

Removing the app, and undoing OpsCrew’s changes

Section titled “Removing the app, and undoing OpsCrew’s changes”

npx opscrew destroy removes only what OpsCrew set up for the app: its containers and runtime files, its site on the edge, the deploy workflow’s secrets, the DNS records or tunnel it created, and the app’s hostnames in OpsCrew’s rules in your Cloudflare zone (other OpsCrew apps there keep theirs; see Removing an app). The other apps on the server keep running; the shared edge goes with the last app. It asks you to type the app’s name first, and never deletes, rebuilds or reinstalls your server.

  • The app’s data (/srv/data/<app>, its Docker volumes and its bucket) stays unless you add --delete-data; deleting it needs the app’s name typed as well. Its name stays reserved for the same project, which can take the kept data again.
  • npx opscrew destroy --undo-host also reverses OpsCrew’s own changes to the server (the deploy user, its SSH and firewall settings, automatic updates, the runtime directories), from the record in /etc/opscrew/host.json, as a separate plan you approve. It deletes the deploy user OpsCrew created and its home directory, so it asks you to type the app’s name again (the name you typed for removing the app does not count for it). Docker stays installed, because other software may use it. It also works after an earlier destroy, and only once no other OpsCrew app is set up on the server.
  • In scripts: npx opscrew destroy --confirm <app name>; the undo plan then needs --confirm <app name> together with --approve <its hash>, which the run prints.

Moving an app between your own server and Hetzner Cloud or DigitalOcean is not supported yet.