← Back to Blog

A Staging Server VPS with Your Own CI Runner and Branch Previews

Published · by RS Computers

staging server CI/CD GitHub Actions self-hosted runner GitLab Runner preview environments Caddy Docker Debian 13

A staging server VPS holds the parts of your release process that shouldn't touch production: a production-like copy of your app, your own CI runner and short-lived previews of each branch, on a Linux virtual machine you control and behind a password. You pick the versions, the disk and the network, so staging behaves like production and your builds stop eating GitHub's or GitLab's monthly minutes.

TL;DR:

Why move CI and staging onto your own server?

Nobody builds a staging box for fun. Something small forces it. The private repo runs out of Actions minutes in week three, say, or a client wants a link to a feature that only exists on your laptop.

The limits are public. GitHub's billing docs include 2,000 Actions minutes a month for private repos on Free and 3,000 on Pro and Team, and list self-hosted runner usage as free. GitLab.com Free has 400 compute minutes, and your own runners don't touch either quota. Under GitHub's job limits, a hosted job is stopped after 6 hours and a self-hosted one after 5 days.

Speed matters more. A hosted private-repo runner is a fresh VM (2 vCPU, 8 GB of RAM, 14 GB of SSD) that forgets everything after each job, and the Actions cache includes 10 GB per repository (anything above that is billed) and evicts entries unused for 7 days. On your own box, Docker's layer cache and yesterday's node_modules are simply still there. On an 8 vCPU / 16 GB test server on our platform, the Docker image of a small TypeScript Express app took 20 to 25 seconds to build from an empty cache and about 2.4 seconds to rebuild after a one-line code change.

A VPS also has fixed IPv4 and IPv6, so a private database can let in exactly one host. And price lists move: in December 2025 GitHub announced a per-minute charge for self-hosted runners, then postponed it a day later, after the Hacker News thread on the change drew more than 500 comments in six hours. Ten months on there's still no new date, but the charge was postponed, not withdrawn.

When not to bother: a four-minute pipeline with minutes to spare. Self-hosting trades minutes for patching, and the patching never stops.

Staging that matches production, or it's a demo

Staging is the copy of production you release to first. It should differ from production in data and secrets, never in software: same Debian release, same PostgreSQL major version, same proxy headers. The Twelve-Factor App calls this dev/prod parity, and a staging box that drifts turns every release into a coin toss. Our PostgreSQL, MySQL and Redis guide covers the database side. If production is a site built as in our WordPress hosting guide, staging gets the same panel and the same PHP version.

Self-hosted runners and PaaS tools, compared

A self-hosted runner is an agent on your machine that fetches jobs from GitHub, GitLab or Forgejo over outbound HTTPS, so it needs no open port. A self-hosted PaaS is a Heroku-style dashboard that deploys apps on your server, often with a preview per pull request.

OptionPick it whenThe catch
GitHub Actions runner (v2.337.0)Your code is on GitHub and minutes run out, or jobs must reach a private network.Private repos only. Docker access makes every job root on the box.
GitLab Runner (19.4)GitLab.com's 400 free minutes don't last the month.Never the shell executor or privileged Docker on a shared box.
Forgejo or Gitea runnerYou moved to Forgejo, Gitea or Codeberg. No public IP needed.Host labels give no isolation at all.
DIY: runner, Caddy, ComposeYou want every moving part visible.You write the preview jobs yourself (shown below).
Coolify (v4.3.23)You want a dashboard with per-PR previews that delete themselves on close.Needs its own server. At least 11 critical CVEs came out in December 2025 and January 2026, and heise reported five more in July 2026, three months after v4.0.0 ended the beta.
Dokploy (v0.30.8)You want previews that stay off until you enable them, 3 per app by default.Debian 13 isn't on its tested list. Until v0.24.3 (July 2025), any pull request deployed itself and could expose environment variables through its preview link. In a July 2026 advisory, any logged-in member of versions 0.29.3 to 0.29.8 could run commands as root through a branch field.
CapRover (v1.15.4)You want the lightest PaaS, about 1 GB of RAM.No native PR previews. Default password captain42 until you change it.

Our take: if you already think in Docker Compose, go DIY. When a deploy hangs, you'll know exactly which file to open. Pick Coolify or Dokploy when people who don't live in a terminal need previews, and give it a server of its own, since each brings its own proxy for ports 80 and 443. Both publish security advisories in batches, so update the day one lands. Create Coolify's admin account right after install: the first visitor to register becomes admin.

Running CI and staging on RS Computers

RS Computers runs KVM virtual machines in Amsterdam, Prishtina and Dublin. Each one is a full VM with its own kernel, so Docker and every runner here behave as on any other Linux server. All plans have NVMe storage and unmetered traffic, on a 1 Gb/s port for VPS plans and 10 Gb/s for VDS plans.

Each server has its own IPv4 and IPv6 address. Free weekly backups cover the whole server and restore from the client area, where you also upgrade when the test suite outgrows the plan. You pick a Linux distribution at order; Windows Server, handy for a Windows build agent, is offered only on VPS Mini and the VDS plans (see our remote desktop guide). Amsterdam and Dublin are inside the EU. See the plans page for which regions can be ordered today.

Sizing a staging server VPS

There's no published sizing for this mix, so these are our own estimates. For reference, GitHub's hosted private-repo runner has 8 GB of RAM. Coolify asks for at least 2 GB of RAM and 10 GB of free disk, and Dokploy for 2 GB of RAM and 30 GB of disk.

PlanFits
VPS Nano (1 vCPU, 1 GB, 20 GB NVMe, 1 Gb/s)Lint and unit tests without Docker builds, or Mailpit beside a bigger staging box.
VPS Micro (2 vCPU, 2 GB, 40 GB NVMe, 1 Gb/s)One runner doing one job at a time, or a single small staging site. Tight for Coolify or Dokploy.
VPS Mini (4 vCPU, 4 GB, 80 GB NVMe, 1 Gb/s)Staging plus a few previews, or Docker builds with a warm layer cache. The smallest plan with Windows Server.
VDS Small (4 vCPU, 8 GB, 240 GB NVMe, 10 Gb/s)The RAM of GitHub's hosted runner with about 17 times its disk: runner, staging and previews together.
VDS Medium (8 vCPU, 16 GB, 480 GB NVMe, 10 Gb/s)Parallel jobs with browser tests, plus staging with a full-size anonymised database.
VDS Large (16 vCPU, 32 GB, 960 GB NVMe, 10 Gb/s)A team's shared runners or many clients' previews, though two smaller boxes often split better.

Our default is VDS Small, provided it only ever holds anonymised data and test keys. Otherwise give the runner its own VPS Mini.

Setting up the box on Debian 13

The steps target Debian 13, with Ubuntu 24.04 differences noted, and each block starts with the user who types it. First point A and AAAA records for staging.YOUR_DOMAIN at the server. New to systemd and SSH keys? Start with our Linux and DevOps lab guide.

1. Base system and firewall

Update the system first. Our Debian 13 image already has sudo, curl, wget and gnupg, so on our servers apt mostly adds ufw and git; the full list keeps the block working on other images. SSH, HTTP and HTTPS go on the allow list before the firewall is switched on:

# as root
apt update && apt full-upgrade -y
apt install -y sudo ca-certificates curl gnupg ufw git jq wget
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 443/udp
ufw --force enable
ufw status verbose

You should see Status: active and the four rules, each twice (once as v6).

2. Move /tmp back to disk (Debian 13 only)

Debian 13 keeps /tmp in RAM, up to half the memory, so a build that unpacks gigabytes there can starve a small server. Mask that mount and reboot to put /tmp back on disk:

# as root
systemctl mask tmp.mount
reboot

After you log back in, df -h /tmp shows your disk instead of tmpfs. Ubuntu 24.04 already uses the disk; skip this there.

3. Docker Engine

Old tutorials use apt-key, which Debian 13 dropped. Add Docker's signed repository the current way, then install the engine and Compose:

# as root
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker run --rm hello-world

The last command prints Hello from Docker!. On Ubuntu, write ubuntu instead of debian in both URLs and ${UBUNTU_CODENAME:-$VERSION_CODENAME} in the Suites line.

4. Caddy from Caddy's repository

Debian's packaged Caddy is 2.6.2, too old for the basic_auth directive used below, so take Caddy from its own repository instead:

# as root
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install -y caddy
caddy version

It prints v2.11.7 or newer. If you see v2.6.2, the repository file is missing; rerun the curl lines and install again. Our reverse proxy and SSL comparison explains why Caddy rather than nginx or Traefik.

5. A staging site nobody else can reach

Google says robots.txt doesn't keep pages out of its index, and noindex only works on pages it can fetch. So a password or allow-list comes first and noindex is the backstop. Set it in Caddy, not in the app, where it could follow a copy of staging into production.

Publish the staging app on loopback only ("127.0.0.1:8080:80" in its compose.yaml), because Docker routes published ports around ufw (our Docker on a VPS guide shows that trap step by step). Then make a password hash for the team login:

# as root
caddy hash-password

It asks for the password twice and prints a line starting with $2a$14$. Now write the staging site. Fill in the capitals before pasting; the last three lines tidy the indentation, check the file and reload Caddy:

# as root
cat > /etc/caddy/Caddyfile <<'EOF'
staging.YOUR_DOMAIN {
    header X-Robots-Tag "noindex, nofollow"
    @outside not remote_ip YOUR_OFFICE_IP
    route {
        abort @outside
        basic_auth {
            team YOUR_HASH
        }
        reverse_proxy 127.0.0.1:8080
    }
}
EOF
caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

Validation prints Valid configuration. From an allowed address, curl -I https://staging.YOUR_DOMAIN returns 401 with an x-robots-tag header; from anywhere else the connection just closes, and curl reports Empty reply from server or an HTTP/2 INTERNAL_ERROR. The certificate still arrives, because Caddy answers Let's Encrypt's check before the route runs. If you see unrecognized directive: basic_auth, you're still on Debian's old Caddy. The route block drops strangers before any password prompt. Add your office's IPv6 range to remote_ip too, or you'll lock yourself out the day your browser prefers IPv6.

6. The GitHub Actions runner

The runner refuses to run as root, so it gets its own user. Docker group membership lets its jobs use Docker and, as Docker's docs warn, grants root-level privileges: fine for staging, never for production. Create the user:

# as root
useradd --create-home --shell /bin/bash gha
usermod -aG docker gha

id gha lists docker. Switch with su - gha, then fetch runner v2.337.0 and check its checksum:

# as the gha user (switch from root with: su - gha)
mkdir actions-runner && cd actions-runner
curl -O -L https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz
echo "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613  actions-runner-linux-x64-2.337.0.tar.gz" | sha256sum -c
tar xzf ./actions-runner-linux-x64-2.337.0.tar.gz

The checksum line ends in OK. If a newer release is out when you read this, take its version and checksum from the runner releases page; after that the runner updates itself. Type exit to get back to root and install the libraries the runner needs:

# as root
cd /home/gha/actions-runner
./bin/installdependencies.sh

It ends with Finish Install Dependencies; if the runner later complains about missing Libicu dependencies, this step was skipped. Back as gha (su - gha), register the runner with a staging label. The token comes from Settings, Actions, Runners, New self-hosted runner, and it expires after an hour:

# as the gha user
cd ~/actions-runner
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO --token YOUR_TOKEN --name staging-1 --labels staging --unattended

It ends with Settings Saved. If you see Must not run with sudo, you're still root. Exit to root again and install the service, naming the user it runs as:

# as root
cd /home/gha/actions-runner
./svc.sh install gha
./svc.sh start
./svc.sh status

Status shows active (running), and GitHub lists the runner as Idle. A workflow file in your repository then deploys main on every push:

# .github/workflows/staging.yml
name: staging
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: [self-hosted, linux, x64, staging]
    steps:
      - uses: actions/checkout@v7
      - run: docker compose -p staging up -d --build

The run in the Actions tab names staging-1 as its runner. Jobs that wait forever mean a label mismatch. On Ubuntu, add GitHub's documented needrestart exception so package updates don't restart the runner mid-job.

Why not --ephemeral? An ephemeral runner does one job and unregisters, as GitHub recommends, but on a single box that means a fresh token per job and no warm caches. With a private repo and trusted pushers, a persistent runner plus cleanup is a fair trade. Just treat the warm cache as shared state. In May 2026 a fork's pull request ran through a pull_request_target workflow at TanStack and poisoned the Actions cache; the release job restored it and 84 malicious versions of 42 npm packages went out in six minutes, says TanStack's postmortem. Code you haven't reviewed must never write to a cache your deploy job reads.

7. GitLab Runner or Forgejo Runner instead

GitLab Runner installs from GitLab's own apt repository. Create a project runner under Settings, CI/CD, Runners, register it as root with --executor docker and its glrt- token, and keep that executor unprivileged. If the log says Running in user-mode, you registered without root and the service will never see the runner. Forgejo Runner (v13.2.0) is one GPG-signed binary whose every command needs -c with its config file.

Per-branch preview environments on a wildcard subdomain

A preview environment is a temporary copy of the app built from one branch, at its own URL such as feature-login.preview.example.com. It's what you send a client instead of a screen recording.

One certificate per preview runs into Let's Encrypt's limit of 50 per registered domain every 7 days, and each one lands in Certificate Transparency logs, the public record of every certificate issued. A 2018 ACM study found scanners acting on those logs within minutes. One wildcard for *.preview.YOUR_DOMAIN avoids both. Wildcards need the DNS challenge, so Caddy needs a DNS plugin. With Cloudflare, create an API token with Zone.Zone:Read and Zone.DNS:Edit, limited to that one zone, and add DNS-only A and AAAA records for *.preview.YOUR_DOMAIN.

That token on the server is the weak spot. Let's Encrypt announced DNS-PERSIST-01 in February 2026, a challenge that reads one standing TXT record so no DNS API key has to live on the box. In June 2026 its staff put the rollout on hold until an open issue in the specification is settled, so for now the token stays.

8. Caddy with the DNS plugin

Caddy's download server builds a binary with the Cloudflare module included. Fetch it and check the module is really there:

# as root
cd /root
curl -o caddy "https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com/caddy-dns/cloudflare"
chmod +x caddy
./caddy list-modules | grep cloudflare

It prints dns.providers.cloudflare; if not, stop. Next, swap in the new binary with a dpkg diversion, the method Caddy's docs describe:

# as root
dpkg-divert --divert /usr/bin/caddy.default --rename /usr/bin/caddy
mv /root/caddy /usr/bin/caddy.custom
update-alternatives --install /usr/bin/caddy caddy /usr/bin/caddy.default 10
update-alternatives --install /usr/bin/caddy caddy /usr/bin/caddy.custom 50
systemctl restart caddy
caddy list-modules | grep cloudflare

The module prints again, now from the live binary. Update it with caddy upgrade, which keeps the plugin, then systemctl restart caddy. Apt updates only replace caddy.default, so the plugin build stays in charge. The download server can trail a release by days: when we re-ran these steps it still built v2.11.6, three days after v2.11.7 fixed 2.11.6's crash when proxying over HTTP/2. If caddy version shows 2.11.6, run caddy upgrade again once the server catches up.

9. Token, previews folder and wildcard site

Three things happen next: the token goes into a root-only file that systemd hands to Caddy, the previews folder appears, and gha gets permission to reload Caddy through sudo and nothing else. Put your Cloudflare token in place of YOUR_CLOUDFLARE_TOKEN first:

# as root
mkdir -p /etc/systemd/system/caddy.service.d /etc/caddy/previews
printf '[Service]\nEnvironmentFile=/etc/caddy/.env\n' > /etc/systemd/system/caddy.service.d/env.conf
echo 'CF_API_TOKEN=YOUR_CLOUDFLARE_TOKEN' > /etc/caddy/.env
chmod 600 /etc/caddy/.env
chown gha:gha /etc/caddy/previews
echo 'gha ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy' > /etc/sudoers.d/gha-caddy
chmod 440 /etc/sudoers.d/gha-caddy
visudo -c
systemctl daemon-reload
systemctl restart caddy

visudo -c reports parsed OK. Now append the wildcard site to the Caddyfile and reload, again with your own values in place of the capitals:

# as root
cat >> /etc/caddy/Caddyfile <<'EOF'

*.preview.YOUR_DOMAIN {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    header X-Robots-Tag "noindex, nofollow"
    basic_auth {
        client YOUR_HASH
    }
    import /etc/caddy/previews/*.caddy
    handle {
        abort
    }
}
EOF
systemctl reload caddy

With a broken config the reload fails and Caddy keeps the old one. Within a minute, journalctl -u caddy | grep -i "certificate obtained" shows the wildcard. If you see module not registered: dns.providers.cloudflare, the service still runs the stock binary. If the reload fails with API token ... appears invalid, correct /etc/caddy/.env and run systemctl restart caddy; a reload doesn't reread that file.

10. Deploy and clean up per pull request

DNS labels max out at 63 characters and a dot adds a label the wildcard won't cover, so branch names become slugs. The workflow below deploys each pull request to its own subdomain and removes it on close. It expects your compose.yaml to publish "127.0.0.1:${PORT}:3000":

# .github/workflows/preview.yml
name: preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
jobs:
  preview:
    runs-on: [self-hosted, linux, x64, staging]
    env:
      BRANCH: ${{ github.head_ref }}
      PR: ${{ github.event.number }}
    steps:
      - run: |
          SLUG=$(echo "$BRANCH" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]/-/g' | cut -c1-40 | sed 's/-*$//')
          echo "SLUG=$SLUG" >> "$GITHUB_ENV"
          echo "PORT=$((3100 + PR))" >> "$GITHUB_ENV"
      - uses: actions/checkout@v7
        if: github.event.action != 'closed'
      - name: Deploy
        if: github.event.action != 'closed'
        run: |
          docker compose -p "preview-$SLUG" up -d --build
          printf '@%s host %s.preview.YOUR_DOMAIN\nhandle @%s {\n  reverse_proxy 127.0.0.1:%s\n}\n' "$SLUG" "$SLUG" "$SLUG" "$PORT" > "/etc/caddy/previews/$SLUG.caddy"
          sudo systemctl reload caddy
      - name: Tear down
        if: github.event.action == 'closed'
        run: |
          docker compose -p "preview-$SLUG" down --volumes --remove-orphans
          rm -f "/etc/caddy/previews/$SLUG.caddy"
          sudo systemctl reload caddy

A pull request from feature-login shows up at feature-login.preview.YOUR_DOMAIN behind the password, and closing it removes it from docker compose ls. The branch name enters through env:, as GitHub advises for untrusted input. Keep "Run workflows from fork pull requests" off, and prune abandoned previews weekly with docker compose -p PROJECT_NAME down --volumes.

Seed data: realistic, not real

A production dump on staging is still personal data. GDPR's Recital 26 keeps pseudonymised data in scope; only truly anonymous data falls outside, and hashed emails don't qualify. We prefer a seed script that generates fake records. Second best is PostgreSQL Anonymizer's anon.anonymize_database(), run on a restored copy, never on production.

Catch staging's outgoing mail with Mailpit (docker run -d --name mailpit -p 127.0.0.1:8025:8025 -p 127.0.0.1:1025:1025 axllent/mailpit), and use your payment provider's test keys. Forget them, and a test checkout on a preview charges a real card. For a shop, our WooCommerce and PrestaShop guide covers restoring a staging copy of the store.

Is a self-hosted runner safe?

On a private repo with people you trust, yes, with care. On a public one, no. GitHub's security guidance says self-hosted runners "should almost never be used for public repositories", because a fork's pull request brings its own code. In a write-up published in January 2024, researchers John Stawinski and Adnan Khan described how a merged typo fix made them PyTorch contributors. Their next pull request then ran code on its self-hosted runners and collected GitHub tokens with access to over 93 repositories. And GitGuardian found that 59% of the machines compromised by the Shai-Hulud 2.0 npm worm in late 2025 were CI/CD runners. What we'd do:

And delete what you stop using: the Midnight Blizzard breach Microsoft disclosed in January 2024 began with a password spray on a legacy, non-production test account that had no multifactor authentication.

Upkeep: patches, full disks and backups

The GitHub runner updates itself, and GitHub stops sending jobs to runners more than 30 days out of date. Old actions need the same care: runners switched JavaScript actions to Node 24 on 16 June 2026, and on 23 September 2026 GitHub removed Node 20 along with the variable that let you opt back, so an action that only works on Node 20 now breaks. Bump it to a current major version. Patch the OS weekly with apt update && apt upgrade; GitLab Runner updates through apt, the custom Caddy through caddy upgrade and a restart.

Disk is what kills long-lived runners: layers and build cache pile up until jobs fail with no space left on device. A daily cron job that prunes anything older than a week keeps it in check:

# as root
apt install -y cron
cat > /etc/cron.daily/docker-prune <<'EOF'
#!/bin/sh
docker image prune -af --filter "until=168h"
docker builder prune -f --filter "until=168h"
EOF
chmod 755 /etc/cron.daily/docker-prune
run-parts --test /etc/cron.daily

The last command lists docker-prune among the daily scripts, and docker system df shows what's reclaimable. If a checkout fails with permission denied, a Docker step left root-owned files in _work.

The free weekly backup covers the whole server; keep an extra copy of /etc/caddy with our off-site backup guide. GitHub removes runners that stay offline for over 14 days, so watch the box with Uptime Kuma and Grafana.

Frequently asked questions

Can staging and production run on the same VPS?

Not if staging runs CI jobs, because runner code can read anything production keeps on the box. Give staging, previews and the runner a second server that holds only anonymised data.

Are self-hosted GitHub Actions runners free?

Yes. GitHub's billing docs list self-hosted runner usage as free, so you pay only for the server. A per-minute charge announced in December 2025 was postponed a day later and still had no new date in October 2026.

Is it safe to use a self-hosted runner on a public repository?

No. GitHub recommends self-hosted runners only for private repositories, because a fork's pull request can run its own code on your machine. Public projects should stay on GitHub-hosted runners; if they must self-host, use ephemeral runners on throwaway machines wiped after every job.

How much RAM does a GitHub Actions or GitLab runner need?

The runner itself needs little; your jobs set the size. GitHub's hosted private-repo runner and GitLab.com's default small runner both have 8 GB, a fair target if you build Docker images.

Is noindex enough to hide a staging site?

No. Noindex only works when Google can fetch the page, and other bots can ignore it. Use basic auth or an IP allow-list, with the X-Robots-Tag header as a backup.

Can I copy production data to staging under GDPR?

Only if you treat the copy as personal data, because GDPR Recital 26 says pseudonymised data still is. Use synthetic or properly anonymised data, or keep real data inside the EU (Amsterdam or Dublin) and secure it like production.

Can RS Computers set up the runner and staging site for me?

Yes. Message us on Telegram or email info@rscomputers-ks.com with your Git host and the runner you want, and we'll suggest a plan and quote the setup.

From order to first green deploy

For a first staging server VPS, order VDS Small with Debian 13 (or VPS Mini if previews can wait), in Amsterdam or Dublin if staging might ever see personal data. Create the DNS records while it provisions, then work through steps 1 to 6. Within the hour, a push to main deploys to a private staging site from your own runner; previews take an afternoon more. Start on the VPS and VDS plans page.

← All articles

Chat on Telegram