# From Local to Live: Hosting Your Apps on VPS with Docker and GitHub A step-by-step guide for aspiring developers on how to host web applications on a Virtual Private Server (VPS) using Docker containers and GitHub Workflows. ## Step 1: Acquire a VPS Provisioning a VPS is now easier and more affordable than ever. Many providers offer budget-friendly solutions, including: * Hostinger * DigitalOcean * RackNerd * Superhosting * ...and many more. >"Tip: Choose a server located near your primary audience (e.g., Europe), but don’t worry too much—servers in the US will only add a few milliseconds of latency." **Recommended Specs:** For a personal portfolio or a few small sites, 1 GB RAM and 40 GB SSD is more than enough. Don’t overspend! **Operating System:** Stick with Ubuntu (e.g., 20.04 or 22.04) unless you’re already comfortable with another Linux distribution. Most tutorials and community support are Ubuntu-focused. **Example Setup:** This tutorial uses a 1 GB RAM, 24 GB SSD, Ubuntu 20.04 server from RackNerd. Once you’ve purchased your server, you’ll receive: * An IP address * Login credentials (usually ``root`` and a password) To access your server, you’ll need an SSH-capable terminal. Popular options include PuTTY (Windows) and Tabby Terminal (cross-platform). ___ >"Alternative: You can also repurpose an old computer or laptop as your own VPS!" ___ ## Step 2: Initial VPS setup After logging in to your VPS, perform some essential setup steps. 1. Update the Server ```bash apt update apt upgrade ``` *This may take a while, depending on your server’s initial state.* ___ 2. Create a new user: ```bash adduser krifod ``` You will be asked a few questions, starting with the account password. * Set a strong password. * You can skip the additional information prompts by pressing ``ENTER``. ___ 3. Grant Administrative Privileges: ```bash usermod -aG sudo krifod ``` ___ 4. Install and Configure the Firewall (UFW) Install UFW: ```bash apt install ufw ``` Check available applications: ```bash ufw app list ``` We need to make sure that the firewall allows SSH connections so that we can log back in next time. You should see: ``` Available applications: OpenSSH ``` Allow SSH connections: ```bash ufw allow OpenSSH ``` Enable the firewall: ```bash ufw enable ``` Type ``y`` and press ``ENTER`` to proceed. Check status: ```bash ufw status ``` You should see output similar to: ``` Status: active To Action From -- ------ ---- OpenSSH ALLOW Anywhere OpenSSH (v6) ALLOW Anywhere (v6) ``` >Note: The firewall now blocks all connections except SSH. If you add more services later, remember to allow their ports. Docker container ports ignore the firewall rules and you don't need to do anything. ___ 5. (Optional) Configure SSH *Consider setting up SSH key authentication for better security.* ___ 6. Log in as Your New User Disconnect and reconnect using your new user credentials. ___ ## Step 3: Install Docker Engine We'll follow the [official Docker documentation](https://docs.docker.com/engine/install/ubuntu/). 1. Set Up the Repository Add Docker's official GPG key: ```bash sudo apt-get update sudo apt-get install ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc ``` Add the Docker repository: ```bash echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update ``` ___ 2. Install the Docker packages ```bash sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` ___ 3. Verify the installation: ```bash sudo docker run hello-world ``` >This command downloads a test image and runs it in a container. When the container runs, it prints a message and exits. ___ 4. Post-Installation Steps: Create the ``docker`` group and add your user: ```bash sudo groupadd docker sudo usermod -aG docker $USER ``` Log out and back in, or run: ```bash newgrp docker ``` Test Docker without ``sudo``. ```bash docker run hello-world ``` ___ 5. Enable Docker to Start on Boot On Debian and Ubuntu, the Docker service starts on boot by default. To automatically start Docker and containerd on boot for other Linux distributions using systemd, run the following commands: ```bash sudo systemctl enable docker.service sudo systemctl enable containerd.service ``` To disable auto-start: ```bash sudo systemctl disable docker.service sudo systemctl disable containerd.service ``` ___ ## Step 4: Prepare your Application Now it’s time to get your application ready for deployment. For this tutorial, we’ll use an [ASP.NET MVC app](https://judge.softuni.org/Contests/4559/CSharp-Web-Development-Basics-Exam-18-February-2024) as an example, but the process is similar for most web apps. 1. Make Your Connection String Configurable Instead of hardcoding your database connection string, use environment variables. This is more secure and flexible, especially for deployments. Open your `appsettings.json` and replace the connection string with a parameterized version: **For MSSQL:** ```json "DefaultConnection": "Server=${DB_SERVER},${DB_PORT};Database=${DB_DATABASE};User Id=${DB_USERNAME};Password=${DB_PASSWORD};Trust Server Certificate=True;" ``` **For PostgreSQL:** ```json "DefaultConnection": "Host=${DB_HOST};Port=${DB_PORT};Database=${DB_DATABASE};Username=${DB_USERNAME};Password=${DB_PASSWORD};" ``` >"Note: Adjust the string format to match your database provider." ___ 2. Update Your Application to Use Environment Variables In your `Program.cs`, change how you read the connection string: **Before:** ```csharp var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); ``` **After:** ```csharp var connectionString = builder.Configuration["ConnectionStrings:DefaultConnection"] .Replace("${DB_SERVER}", Environment.GetEnvironmentVariable("DB_SERVER") ?? "localhost") .Replace("${DB_PORT}", Environment.GetEnvironmentVariable("DB_PORT") ?? "1433") .Replace("${DB_DATABASE}", Environment.GetEnvironmentVariable("DB_DATABASE") ?? "defaultdb") .Replace("${DB_USERNAME}", Environment.GetEnvironmentVariable("DB_USERNAME") ?? "SA") .Replace("${DB_PASSWORD}", Environment.GetEnvironmentVariable("DB_PASSWORD") ?? "password"); ``` >"Tip: Always provide fallback/default values in case an environment variable is missing." ___ 3. Push Your Code to GitHub Commit your changes and push your repository to [GitHub](https://github.com/KaiserDMC/SeminarHub-Example). ___ 4. Set Up Docker Hub Credentials in GitHub To automate Docker image builds, you’ll need to connect your GitHub repository to Docker Hub. * Go to your repository’s **Settings → Security → Secrets and variables → Actions**. * Click **New repository secret** and add your Docker Hub token as `DOCKERHUB_PASSWORD`. * You can generate a token in Docker Hub under **Account Settings → Security → Personal Access Tokens**. * Add your Docker Hub username as a variable: * Switch to the **Variables** tab, click **New repository variable**, and name it `DOCKERHUB_USERNAME`. >"Note: You can get a new token by login on [Docker Hub](https://app.docker.com/) and then going to your account settings and under Personal access tokens" you click "Generate new token"." ___ >"Security Note: Using environment variables for secrets is better than hardcoding, but for production, consider a dedicated secrets manager (like Azure Key Vault, AWS Secrets Manager, HashiCorp Vault or Infisical). For learning and development, this method is fine." ## Step 5: Build and Push Your Docker Image Now it’s time to package your app as a Docker image and push it to Docker Hub automatically using GitHub Actions. 1. Create a GitHub Workflow In your repository, create a new file at `.github/workflows/docker-image.yml` with the following content: ```yml name: Build and Push Docker Image on: push: branches: - master jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write attestations: write id-token: write steps: - name: Checkout Repository uses: actions/checkout@v4 - name: Log in to DockerHub uses: docker/login-action@v3 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and Push Docker Image uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 with: context: . file: Dockerfile push: true tags: ${{ vars.DOCKERHUB_USERNAME }}/seminar-hub:latest - name: Verify Docker Image run: docker images ``` >"Tip: Make sure your Docker Hub username and password/token are set as repository variables and secrets in GitHub (DOCKERHUB_USERNAME and DOCKERHUB_PASSWORD)." ___ 2. Create a Dockerfile Place your `Dockerfile` in the root of your project (where Program.cs and your main .csproj file are): ```dockerfile FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base USER app WORKDIR /app EXPOSE 8080 EXPOSE 8081 FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build ARG BUILD_CONFIGURATION=Production WORKDIR /src COPY ["SeminarHub.csproj", "."] RUN dotnet restore "SeminarHub.csproj" COPY . . WORKDIR "/src" RUN dotnet build "SeminarHub.csproj" -c $BUILD_CONFIGURATION -o /app/build WORKDIR /src RUN dotnet build "SeminarHub.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Production RUN dotnet publish "SeminarHub.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENV ASPNETCORE_ENVIRONMENT=Production ENTRYPOINT ["dotnet", "SeminarHub.dll"] ``` >"Note: If your project is more complex and each section is separated in its own .csproj file, you need to COPY all of them into /src. The BUILD_CONFIGURATION=Production argument is used to avoid developer error messages (you can set it to something else if needed). If your database data is in a separate project, make sure it is copied in the final step as well." >"Note: Visual Studio (not VS Code) can auto-generate Dockerfiles for your project. If you’re unsure, you can use that feature to get started." ___ 3. (Optional) Add a `.dockerignore` File To keep your Docker image small, add a `.dockerignore` file to exclude files and folders you don’t need in your image. ___ 4. Push Your Changes Commit and push your workflow and Dockerfile to GitHub. GitHub Actions will automatically build and push your Docker image to Docker Hub every time you push to the `master` branch. You can monitor the build progress under the Actions tab in your GitHub repository. ___ ## Step 6: Orchestrate with Docker Compose Now let’s set up Docker Compose to manage your app, database, and networking on your VPS. 1. Create a Docker Compose File Create a `docker-compose.yaml` file: ```yml services: webapp: image: kaiserdmc/seminar-hub:latest container_name: seminarhubpage environment: - ASPNETCORE_ENVIRONMENT=Production - DB_SERVER=${DB_SERVER} - DB_PORT=${DB_PORT} - DB_DATABASE=${DB_DATABASE} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} ports: - "8080:80" - "8081:8081" user: "root" networks: - seminarhubpage_network depends_on: - mssql volumes: - webapp-data:/app/data mssql: image: mcr.microsoft.com/azure-sql-edge:latest container_name: seminarhubpage_db environment: - ACCEPT_EULA=1 - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD} - MSSQL_DATA_DIR=/var/opt/mssql/data - MSSQL_LOG_DIR=/var/opt/mssql/log - MSSQL_BACKUP_DIR=/var/opt/mssql/backup ports: - "1433:1433" networks: - seminarhubpage_network volumes: - mssql-data:/var/opt/mssql db-init: image: mcr.microsoft.com/azure-sql-edge:latest depends_on: - mssql entrypoint: [ "/bin/bash", "/init-db.sh" ] environment: - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD} - DB_NAME=${DB_DATABASE} - DB_HOST=${DB_SERVER} volumes: - ./init-db.sh:/init-db.sh:ro networks: - seminarhubpage_network networks: seminarhubpage_network: driver: bridge volumes: webapp-data: mssql-data: ``` >"Note: If you want to store other data that needs to persist, such as images under wwwroot, make sure to add those volumes in the first and last part of the script. If you use PostgreSQL, some of the variables differ and would need changing. Same goes for the database data path." ___ 2. Create a `.env` File In the same directory, create a `.env` file to store your environment variables: ```yml MSSQL_SA_PASSWORD=YourStrongPassword123! DB_SERVER=seminarhubpage_db DB_PORT=1433 DB_DATABASE=seminarhub_database DB_USERNAME=sa DB_PASSWORD=YourStrongPassword123! ``` ___ 3. (Optional) Fix Volume Permissions If you run into issues with database volume permissions, run: ```bash # Create the volume docker volume create mssql-data # Create a temporary container to set permissions docker run --rm -v mssql-data:/mssql-data alpine chmod -R 777 /mssql-data ``` ___ 4. Add a Database Initialization Script Create a file named `init-db.sh` in your project directory: ```bash #!/bin/bash # Use the service name as the host DB_HOST=${DB_HOST:-seminarhubpage_db} echo "Waiting for SQL Server to be available at $DB_HOST..." until /opt/mssql-tools/bin/sqlcmd -S $DB_HOST -U sa -P "$MSSQL_SA_PASSWORD" -Q "SELECT 1" &>/dev/null do sleep 2 done echo "SQL Server is up. Creating database if it does not exist..." /opt/mssql-tools/bin/sqlcmd -S $DB_HOST -U sa -P "$MSSQL_SA_PASSWORD" -Q "IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = N'$DB_NAME') BEGIN CREATE DATABASE [$DB_NAME]; END" echo "Database check/creation complete." ``` Make it executable: ```bash cd ~/websites/seminarhub chmod +x init-db.sh ``` >"Why is this needed? When using Azure SQL Edge (or similar images), the container only creates the sa user and the master.dbo schema by default. Your own database (e.g., seminarhub_database) and its schema are not created automatically. This script waits for SQL Server to be ready, then creates your database if it doesn’t exist. If you use a different database image or provider, you may need a different initialization approach." ___ 5. Apply Database Migrations Automatically Add a helper method to your project to apply migrations on startup. Create a file called `WebApplicationExtensions.cs`: ```csharp using Microsoft.EntityFrameworkCore; using SeminarHub.Data; namespace SeminarHub; public static class WebApplicationExtensions { public static void ConfigureMigrations(this WebApplication app) { using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.Migrate(); } } } ``` Then, in your `Program.cs`, call this method after building the app: ```csharp ... var app = builder.Build(); app.ConfigureMigrations(); ... ``` ___ ## Step 7: Deploy and Run Your App on the VPS You’re almost there! Let’s get your app running on your server. 1. Upload Your Files On your VPS, create a directory for your app: ``` mkdir -p ~/websites/seminarhub cd ~/websites/seminarhub ``` Upload your `docker-compose.yaml`, `.env`, and `init-db.sh` files to this directory. You can use an SFTP client like FileZilla, WinSCP, or your terminal. ___ 2. Start Your Containers From your app directory, run: ``` docker compose up -d ``` Check that your containers are running: ``` docker ps ``` You should see your app and database containers listed. ```yml CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 0a394cee2720 kaiserdmc/seminar-hub:latest "dotnet SeminarHub.d…" 22 hours ago Up 22 hours 8080/tcp, 0.0.0.0:8081->8081/tcp, [::]:8081->8081/tcp, 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp seminarhubpage 37c499220b96 mcr.microsoft.com/azure-sql-edge:latest "/opt/mssql/bin/perm…" 22 hours ago Up 22 hours 1401/tcp, 0.0.0.0:1433->1433/tcp, [::]:1433->1433/tcp seminarhubpage_db ``` ___ 3. Open the Firewall Allow HTTP traffic to your server: ```bash sudo ufw allow 80 ``` Now, visit your server’s IP address in your browser. Your app should be live! ___ ## Step 8: Set Up a Domain and HTTPS Let’s make your app accessible via a custom domain and secure it with HTTPS. ### 8.1: Get a Domain Name Register a domain with any provider (e.g., OVHcloud, GoDaddy, Namecheap, SuperHosting). In your domain’s DNS settings, add: * An `A` record for `yourdomain.com` pointing to your VPS IP * An `A` record (or `CNAME`) for `www.yourdomain.com` pointing to your VPS IP >"Note: DNS changes can take up to 48 hours to propagate. ___ ### 8.2: Install Apache as a Reverse Proxy The Apache web server is among the most popular web servers in the world. It’s well documented, has an active community of users, and has been in wide use for much of the history of the web, which makes it a great choice for hosting a website. Install Apache: ```bash sudo apt update sudo apt install apache2 ``` Allow HTTP and HTTPS through the firewall: ```bash sudo ufw allow 'Apache Full' ``` Create a new Apache site config: ```bash sudo nano /etc/apache2/sites-available/your_domain.conf ``` Paste the following, replacing `your_domain` with your actual domain: ```bash # Configuration for kapybara.cloud ServerAdmin webmaster@kapybara.cloud ServerName kapybara.cloud ServerAlias www.kapybara.cloud DocumentRoot /var/www/html ErrorLog ${APACHE_LOG_DIR}/kapybara_error.log CustomLog ${APACHE_LOG_DIR}/kapybara_access.log combined ProxyPass / http://127.0.0.1:8080/ ProxyPassReverse / http://127.0.0.1:8080/ Options Indexes FollowSymLinks AllowOverride None Require all granted ``` >"Note: The last section `Directory` is not needed unless you want to server static files from Apache as well as from Docker, but I have left it in for the example." Enable the necessary modules and your new site: ```bash sudo a2enmod proxy sudo a2enmod proxy_http sudo a2ensite kapybara_hub.conf sudo a2dissite 000-default sudo systemctl restart apache2 ``` ___ ### 8.3: Secure Your Site with HTTPS (Let’s Encrypt) Let’s Encrypt is a Certificate Authority (CA) that facilitates obtaining and installing free TLS/SSL certificates, thereby enabling encrypted HTTPS on web servers. It simplifies the process by providing a software client, Certbot, that attempts to automate most (if not all) of the required steps. Currently, the entire process of obtaining and installing a certificate is fully automated on both Apache and Nginx. In order to obtain an SSL certificate with Let’s Encrypt, we’ll first need to install the Certbot software on your server. We’ll use the default Ubuntu package repositories for that. Install Certbot for Apache: ```bash sudo apt install certbot python3-certbot-apache ``` Check your Apache config: ```bash sudo apache2ctl configtest ``` If you see `Syntax OK`, run: ```bash sudo certbot --apache ``` and follow the instructions. Follow the prompts to set up your SSL certificate. When asked, choose to **redirect HTTP** to **HTTPS**. Check that auto-renewal is enabled: ```bash sudo systemctl status certbot.timer ``` Test renewal with: ```bash sudo certbot renew --dry-run ``` ___ ## Step 9: Celebrate! That’s it! Your app is now live, running in Docker on your VPS, accessible via your own domain, and secured with HTTPS. ___ ## Step 10: Hosting Multiple Websites on One VPS Once you’ve mastered hosting a single app, you can easily expand your VPS to serve multiple websites! Here’s how to do it: ___ 1. Assign Unique Ports for Each App Each web app must listen on a different port inside Docker and be mapped to a unique port on your VPS. For example: * `exampleone.com` → Docker port `8080` * `exampletwo.org` → Docker port `8082` In your `docker-compose.yaml`: ```yml services: exampleone-app: image: yourdockerhubuser/exampleone:latest container_name: exampleone_app ports: - "8080:80" # ...other config... exampleone-db: # ...db config for exampleone... exampletwo-app: image: yourdockerhubuser/exampletwo:latest container_name: exampletwo_app ports: - "8082:80" # ...other config... exampletwo-db: # ...db config for exampletwo... ``` >"Tip: Make sure each app and its database use unique container names, ports, and environment variables." ___ 2. Update Apache to Proxy Each Domain to the Right App You’ll need a separate Apache config for each website. Each config listens for its domain and proxies requests to the correct Docker port. **Example: Two Sites on One VPS** Create a file like `/etc/apache2/sites-available/exampleone.com.conf` and paste: ```bash # Configuration for exampleone.com and exampleone.net ServerAdmin webmaster@exampleone.com ServerName exampleone.com ServerAlias www.exampleone.com exampleone.net www.exampleone.net DocumentRoot /var/www/html ErrorLog ${APACHE_LOG_DIR}/exampleone-error.log CustomLog ${APACHE_LOG_DIR}/exampleone-access.log combined # Proxy to your app (change port as needed) ProxyPass / http://127.0.0.1:8080/ ProxyPassReverse / http://127.0.0.1:8080/ Options Indexes FollowSymLinks AllowOverride None Require all granted # Redirect all www and .net traffic to https://exampleone.com RewriteEngine on RewriteCond %{HTTP_HOST} ^(www\.)?exampleone\.(com|net)$ [NC] RewriteRule ^ https://exampleone.com%{REQUEST_URI} [END,NE,R=permanent] RewriteCond %{SERVER_NAME} =www.exampleone.net [OR] RewriteCond %{SERVER_NAME} =exampleone.net [OR] RewriteCond %{SERVER_NAME} =www.exampleone.com [OR] RewriteCond %{SERVER_NAME} =exampleone.com RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] ``` And for your second site, `/etc/apache2/sites-available/exampletwo.org.conf`: ```bash # Configuration for exampletwo.org ServerAdmin webmaster@exampletwo.org ServerName exampletwo.org ServerAlias www.exampletwo.org DocumentRoot /var/www/html ErrorLog ${APACHE_LOG_DIR}/exampletwo-error.log CustomLog ${APACHE_LOG_DIR}/exampletwo-access.log combined # Proxy to your app (change port as needed) ProxyPass / http://127.0.0.1:8082/ ProxyPassReverse / http://127.0.0.1:8082/ Options Indexes FollowSymLinks AllowOverride None Require all granted # Redirect all www traffic to https://exampletwo.org RewriteEngine on RewriteCond %{HTTP_HOST} ^(www\.)?exampletwo\.org$ [NC] RewriteRule ^ https://exampletwo.org%{REQUEST_URI} [END,NE,R=permanent] RewriteCond %{SERVER_NAME} =exampletwo.org [OR] RewriteCond %{SERVER_NAME} =www.exampletwo.org RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] ``` >"Note: You can also combine the two configurations in one file!" ___ 3. Enable Your New Sites and Restart Apache Enable the necessary Apache modules and your new site configs: ```bash sudo a2enmod proxy sudo a2enmod proxy_http sudo a2ensite exampleone.com.conf sudo a2ensite exampletwo.org.conf sudo systemctl reload apache2 ``` >"Tip: You can add as many site configs as you need—just use a unique port for each app and update the ProxyPass lines accordingly." ___ 4. Set Up HTTPS for Each Domain Run Certbot for each domain to get a free SSL certificate: ```bash sudo certbot --apache ``` Follow the prompts for each domain. ___ **Now you can host as many sites as you want on a single VPS!** Just repeat the process for each new app and domain.