\chapter{Startup Foundation with Gitea} \ChapterLabel{GiteaFoundation} \index{Gitea}\index{SQLite}\index{Docker Compose}\index{Elastic IP} This chapter records the Inviscid work hub on the existing Amazon EC2 server. AWS is used in place of the assignment's DigitalOcean deployment. Victor completed the domain, DNS, Gitea deployment, and account setup; Payton completed HTTPS, the firewall, persistence testing, and the remaining workflow evidence. \section{Student Pack Domain and DNS} After GitHub Education approved Victor's Student Developer Pack application on October 12, partner benefits activated on October 15. Victor then redeemed Name.com's Student Pack offer. It covered selected extensions rather than the preferred \texttt{inviscid.net}, so he registered \texttt{inviscid.dev} for one year. No registrar-sold SSL product was needed, because Caddy obtains the certificate (Table~\ref{Table:GiteaCertOptions}). \begin{tabularx}{\linewidth}{L{3.8cm}Y} \caption{Student Domain and DNS Record\label{Table:GiteaDomain}}\\ \toprule \textbf{Field} & \textbf{Recorded value}\\ \midrule Provider and eligibility & Name.com, redeemed through the activated GitHub Student Developer Pack benefit\\ Registered domain & \texttt{inviscid.dev}\\ Registration date / term & October 15, 2026 / one year\\ Initial charge & \$0.00\\ Displayed renewal price & \$22.99; verify the current price before renewal\\ Renewal date & October 15, 2027\\ Renewal controls & Automatic renewal ON; WHOIS privacy ON; transfer lock enabled; Victor tracks renewal and keeps payment details current\\ DNS record & A, host \texttt{git}, answer \texttt{100.49.80.94} (Elastic IP), TTL 300 seconds\\ Public service URL & \url{https://git.inviscid.dev/}\\ \bottomrule \end{tabularx} \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/domain-free-checkout.png} \caption{October 15: one-year inviscid.dev registration at a checkout total of zero.} \label{Figure:GiteaDomainCheckout} \end{figure} \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/domain-renewal-details.png} \caption{October 15: registered domain, renewal date and price, auto-renewal, privacy, and transfer-lock settings.} \label{Figure:GiteaDomainRenewal} \end{figure} Victor added the A record in Name.com's DNS manager (Figure~\ref{Figure:GiteaDNSRecord}) and verified it from outside EC2 at 19:17 EDT on October 15: both Cloudflare (1.1.1.1) and Google (8.8.8.8) resolvers returned \texttt{100.49.80.94} (Figure~\ref{Figure:GiteaDNSLookup}). \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/dns-a-record.png} \caption{October 15: saved A record for git.inviscid.dev pointing to 100.49.80.94 with a 300-second TTL.} \label{Figure:GiteaDNSRecord} \end{figure} \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/dns-independent-lookups.png} \caption{October 15: independent Cloudflare and Google resolver lookups both return the expected Elastic IP.} \label{Figure:GiteaDNSLookup} \end{figure} \section{Pinned Gitea Deployment} Gitea runs on the existing \texttt{ssw590-group-web} instance (Ubuntu 26.04 LTS, \texttt{t3.micro}, \texttt{us-east-1}), whose Elastic IP keeps the DNS record valid across restarts. Victor deployed Gitea privately on October 12. On October 15, Payton added Caddy as the HTTPS endpoint and recreated the existing Color Buttons container without a published port on \texttt{inviscid\_services}, so that Caddy could take port 80; the recreate command is in the deployment README. \begin{tabularx}{\linewidth}{L{3.5cm}Y} \caption{Recorded Gitea Deployment\label{Table:GiteaDeployment}}\\ \toprule \textbf{Setting} & \textbf{Value}\\ \midrule Instance & \texttt{i-025a57432604c8d4b}, \texttt{ssw590-group-web}\\ Public / private address & Elastic IP \texttt{100.49.80.94} / \texttt{172.31.19.91}\\ Official image & \texttt{docker.gitea.com/gitea:28.1.0}\\ Container & \texttt{inviscid-gitea-1}\\ Remote working directory & \path{/home/ubuntu/gitea}\\ Persistent volume & \texttt{inviscid\_gitea\_data}, mounted at \path{/data}\\ Docker network & \texttt{inviscid\_services}\\ Restart policy & \texttt{unless-stopped}\\ Published backend & \texttt{127.0.0.1:3000:3000}; no public backend mapping\\ Public URL setting & \url{https://git.inviscid.dev/}\\ TLS proxy & \texttt{caddy:2.10.2}, container \texttt{inviscid-caddy-1}; the only service publishing host ports 80 and 443\\ \bottomrule \end{tabularx} Both images are pinned to version tags rather than \texttt{latest}. The recorded Gitea repository digest is: \begin{minted}[fontsize=\scriptsize]{text} docker.gitea.com/gitea@sha256:f505a0d3a41323b786e05942fc7a4075e07ae7c169fd9485649aa55ee543a21a \end{minted} The non-secret configuration is maintained in \href{https://git.inviscid.dev/Inviscid/SSW590-DevOps/src/branch/main/docker/gitea}{the team's versioned deployment directory} in the Gitea repository. The deployed Compose file and Caddyfile follow. \inputminted{yaml}{docker/gitea/compose.yaml} \inputminted{text}{docker/gitea/Caddyfile} To repeat the installation, copy the directory to the server and run, from it: \begin{minted}{bash} cd ~/gitea sudo docker compose config --quiet sudo docker compose pull sudo docker compose up -d sudo docker compose ps \end{minted} Validation returned without an error and the pinned image pulled (Figure~\ref{Figure:GiteaPull}). \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/compose-validation-pull.png} \caption{October 12: successful Compose validation and pull of the pinned official image.} \label{Figure:GiteaPull} \end{figure} \section{Database and Persistence} SQLite was selected for the small two-person deployment. It requires no separate database service or network port. The database is at \path{/data/gitea/gitea.db}, within the same persistent named volume that holds application configuration and repository data. The volume exists outside the disposable container layer. Generated configuration, database contents, and secrets are not stored in the Git repository. SQLite suits this team's size but may not fit a larger, higher-concurrency deployment. \subsection{Container-Replacement Test} On October 15 at 21:34 EDT, Payton replaced only the Gitea container, using repository \texttt{Inviscid/SSW590-DevOps} and issue \#3 as the recorded data. Volumes were not removed: \begin{minted}{bash} sudo docker compose up -d --force-recreate --no-deps gitea \end{minted} The container ID changed from \texttt{915c6b6d88d2} to \texttt{82ca41970439}, confirming that the container and its writable layer were replaced. The same volume remained mounted at \path{/data}, the 2.2 MiB database file was still present (Figure~\ref{Figure:GiteaPersistence}), and the repository and issue \#3 were unchanged and usable (Figure~\ref{Figure:GiteaPersistenceIssue}). The data survived because Compose reattached the named volume. This is a persistence test, not a backup; see the Architecture and Operation section. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/persistencetest.png} \caption{October 15: container ID, mount, replacement command, new container ID, and database file.} \label{Figure:GiteaPersistence} \end{figure} \begin{figure}[htbp] \centering \begin{minipage}{0.49\linewidth}\centering \includegraphics[width=\linewidth]{figures/gitea/beforeissue.png}\\ \small Before \end{minipage}\hfill \begin{minipage}{0.49\linewidth}\centering \includegraphics[width=\linewidth]{figures/gitea/afterissue.png}\\ \small After \end{minipage} \caption{October 15: issue \#3 before and after the Gitea container replacement.} \label{Figure:GiteaPersistenceIssue} \end{figure} \section{HTTPS and Certificate Renewal} \index{Caddy}\index{Let's Encrypt} \subsection{Certificate Options} Both options below use Let's Encrypt, a free certificate authority that issues 90-day certificates through \gls{acme}. A paid or registrar-sold certificate was rejected because it needs manual installation and renewal. \begin{tabularx}{\linewidth}{L{2.8cm}YY} \caption{Certificate Options\label{Table:GiteaCertOptions}}\\ \toprule \textbf{Criterion} & \textbf{Caddy automatic HTTPS (selected)} & \textbf{certbot with nginx}\\ \midrule Issuance and renewal & Built into the proxy; no extra job & Separate certbot client and renewal timer\\ Configuration & One Caddyfile site block; HTTP-to-HTTPS redirect is automatic & nginx server blocks plus certbot plugin\\ Containers & One proxy container & Proxy plus certbot, or certbot on the host\\ Renewal test & No dry-run command; forced reissue used instead & \texttt{certbot renew --dry-run}\\ \bottomrule \end{tabularx} \Gls{caddy} was selected for fewer moving parts and secure defaults. Its main drawback is the lack of a dry-run renewal command. \subsection{TLS Termination and Evidence} \gls{tls} terminates at Caddy. Caddy forwards plain HTTP to \texttt{gitea:3000} over the private Docker network \texttt{inviscid\_services}; that traffic never leaves the instance. Port 80 is open for HTTP-to-HTTPS redirects and ACME validation. Caddy obtained the Let's Encrypt certificate for \texttt{git.inviscid.dev} at 19:52 EDT on October 15; the current certificate is valid until January 13, 2027 (90 days). From Payton's laptop, \texttt{http://git.inviscid.dev} returned \texttt{308 Permanent Redirect} to \url{https://git.inviscid.dev/}, and the HTTPS URL returned HTTP/2 200 (Figure~\ref{Figure:GiteaExternalTests}). The \texttt{.dev} top-level domain is on browsers' HSTS preload list, so browsers never request plain HTTP for this host; the redirect matters for non-browser clients and was tested with \texttt{curl}. \subsection{Renewal Automation and Test} Caddy renews managed certificates automatically in the background, by default when about one third of the lifetime remains (around mid-December 2026 for this certificate). No cron job or manual command is required. Certificate and ACME account state persist in \texttt{inviscid\_caddy\_data}, so replacing the Caddy container does not force reissuance. Because Caddy has no dry-run command, Payton forced a reissue by deleting the stored certificate and restarting Caddy. This exercises the same unattended ACME path that renewal uses: \begin{minted}{bash} sudo docker compose exec caddy rm -rf \ /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/git.inviscid.dev sudo docker compose restart caddy sudo docker compose logs --since 1m caddy | grep -i obtained \end{minted} Caddy logged \texttt{certificate obtained successfully} without manual intervention, and the serial changed from \texttt{06ADFF\ldots F6A7} to \texttt{06CA6A\ldots 034F} with a new validity window (Figure~\ref{Figure:GiteaRenewal}). This was a forced reissue, not an observed natural renewal, and was run once to stay within Let's Encrypt's duplicate-certificate limit. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/caddy.png} \caption{October 15: certificate serial and validity before and after the forced reissue, with Caddy's log entry.} \label{Figure:GiteaRenewal} \end{figure} \section{Firewall and External Exposure} The only security group attached to the instance is \texttt{ssw590-web-sg} (\texttt{sg-0a9eb6dcfc906e78b}); Figure~\ref{Figure:GiteaArchitecture} shows the resulting network paths. Team source addresses are partially redacted. \begin{tabularx}{\linewidth}{L{1.6cm}L{3.4cm}Y} \toprule \textbf{Inbound TCP} & \textbf{Source} & \textbf{Purpose}\\ \midrule 443 & \texttt{0.0.0.0/0} & HTTPS to Caddy for \texttt{git.inviscid.dev}\\ 80 & \texttt{0.0.0.0/0} & HTTP-to-HTTPS redirect, ACME validation, Color Buttons by IP\\ 22 & \texttt{100.8.x.x/32} & Victor, home\\ 22 & \texttt{155.246.x.x/32} & Victor, campus\\ 22 & \texttt{108.35.x.x/32} & Payton\\ \bottomrule \end{tabularx} Outbound allows all traffic to \texttt{0.0.0.0/0}. The server needs outbound access for package and image downloads, DNS, time synchronization, and Let's Encrypt; the group is stateful, so replies to allowed inbound connections are permitted. The instance has no IPv6 address, so there are no IPv6 rules. Docker also binds ports 80 and 443 on IPv6, but they are unreachable without an IPv6 address. The subnet's network ACL (\texttt{acl-06bdf6081fd8b9d5d}) is the default allow-all in both directions, so the security group is the effective filter (Figures~\ref{Figure:GiteaFirewall} and~\ref{Figure:GiteaNACL}). The Color Buttons app from an earlier assignment is still served over plain HTTP by IP, which does not meet the hostname-based HTTPS rule. It is kept because the instructor expects the instance and this app to stay reachable; it no longer publishes its own port, and giving it an HTTPS hostname is future work. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/security.png} \caption{October 15: the single attached security group with final inbound and outbound rules.} \label{Figure:GiteaFirewall} \end{figure} \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/subnets.png} \caption{October 15: default network ACL on the instance's subnet.} \label{Figure:GiteaNACL} \end{figure} \subsection{Host and Container Exposure} Docker-published ports can bypass host firewall (ufw) rules, so the security group is the public boundary. Figure~\ref{Figure:GiteaPorts} shows that only \texttt{docker-proxy} (Caddy) listens on 80 and 443 and \texttt{sshd} on 22. Gitea's port 3000 listens on \texttt{127.0.0.1} only, and the Color Buttons container and Caddy's admin port 2019 have no host mapping. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/ports.png} \caption{October 15: Docker port mappings and listening sockets on EC2.} \label{Figure:GiteaPorts} \end{figure} \subsection{External Exposure Test} Tests ran from Payton's laptop on October 15, outside EC2, using \texttt{nc -vz -w 5} and \texttt{curl}: \begin{tabularx}{\linewidth}{L{1.5cm}L{3.3cm}L{2.6cm}Y} \toprule \textbf{Port} & \textbf{Source (EDT)} & \textbf{Expected} & \textbf{Actual}\\ \midrule 443 & \texttt{108.35.x.x}, 21:53 & open & connected; HTTP/2 200\\ 80 & \texttt{108.35.x.x}, 21:53 & open, redirect & connected; 308 to HTTPS; IP returns 200 (Color Buttons)\\ 22 & \texttt{108.35.x.x}, 21:53 & open (allowed) & connected\\ 3000 & \texttt{108.35.x.x}, 21:53 & blocked & timed out\\ 222 & \texttt{108.35.x.x}, 21:53 & blocked & timed out\\ 2019 & \texttt{108.35.x.x}, 21:53 & blocked & timed out\\ 22 & \texttt{174.206.x.x} (phone hotspot), 21:53--21:56 & blocked & timed out\\ \bottomrule \end{tabularx} All results matched expectations, so no corrections were needed. The laptop was switched to the hotspot before the final SSH test; \texttt{checkip} afterwards confirmed the hotspot address. Timeouts, rather than refusals, show that the security group drops the traffic before it reaches the instance. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/laptop.png} \caption{October 15: external port, redirect, and HTTPS tests from an allowed source and from a non-allowed hotspot.} \label{Figure:GiteaExternalTests} \end{figure} HTTPS encrypts connections that the firewall admits; it does not close any port. Without the security group, Gitea's port, SSH, or any accidentally published container port could be reached directly. \section{Startup Team Workflow} Victor initialized Gitea with site title \texttt{Inviscid}, created the \texttt{victor} site administrator, and disabled public self-registration. He created a separate regular \texttt{payton} account with a temporary password, which Payton changed after first login; no shared Gitea account is used. \subsection{Organization, Repository, and Roles} Victor created organization \texttt{Inviscid} and added \texttt{payton} to its Owners team, so both members administer the organization and its repositories (Figure~\ref{Figure:GiteaOwners}). Victor leads domain, DNS, and deployment work; Payton leads HTTPS, firewall, persistence, and repository workflow. Victor then pushed the existing project history to the product repository, \texttt{Inviscid/SSW590-DevOps}. Its root \texttt{README.md}, added in PR 7, describes the product, repository layout, team roles, and contribution workflow (Figure~\ref{Figure:GiteaReadme}). The organization and repository are at \url{https://git.inviscid.dev/Inviscid} and \url{https://git.inviscid.dev/Inviscid/SSW590-DevOps}. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/organization-owners.png} \caption{October 15: victor and payton are members of the Inviscid Owners team.} \label{Figure:GiteaOwners} \end{figure} \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/readme.png} \caption{October 15: product repository README.} \label{Figure:GiteaReadme} \end{figure} \subsection{Backlog} The backlog contains five actionable issues, each with acceptance criteria: \begin{tabularx}{\linewidth}{L{0.6cm}YL{2.1cm}L{2.5cm}} \toprule \textbf{\#} & \textbf{Issue} & \textbf{Type} & \textbf{Status}\\ \midrule 1 & Document the initial Gitea deployment and team access & Operations & Closed (PR 2)\\ 3 & Finalize README.md (product and deployment READMEs) & Product & Closed (PR 7)\\ 4 & Enable HTTPS for git.inviscid.dev & Operations & Closed (PR 7)\\ 5 & Configure EC2 firewall & Operations & Closed after tests\\ 6 & Version the Color Buttons app in the repository & Product & Open\\ \bottomrule \end{tabularx} \subsection{Issue-Linked Pull Requests} Gitea numbers issues and pull requests from one sequence, so the two PRs are 2 and 7. \paragraph{PR 2 (Victor, reviewed by Payton).} Victor documented the initial deployment on branch \texttt{codex/gitea-foundation-docs} and opened PR 2 with \texttt{Closes \#1}, requesting Payton's review. Payton reviewed the changes, approved, and merged the PR, which closed issue 1 (Figure~\ref{Figure:GiteaPR2}). \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/pr2merge.png} \caption{October 15: PR 2 approved by Payton and merged, closing issue 1.} \label{Figure:GiteaPR2} \end{figure} \paragraph{PR 7 (Payton, reviewed by Victor).} Payton authored PR 7 from \texttt{payton/caddy-https}. It adds the root README, the Caddy service and Caddyfile, the HTTPS URL settings, and the updated deployment README, with \texttt{Closes \#3} and \texttt{Closes \#4}. Victor reviewed and approved it, and Payton merged it as commit \texttt{1d7c528795}, which closed both issues (Figure~\ref{Figure:GiteaPR7}). The initial \texttt{main} push is not counted as a reviewed merge. \begin{figure}[htbp] \centering \includegraphics[width=\linewidth]{figures/gitea/mypr.png} \caption{October 15: PR 7 linked to issues 3 and 4, approved by Victor, and merged.} \label{Figure:GiteaPR7} \end{figure} \section{Architecture and Operation} Figure~\ref{Figure:GiteaArchitecture} shows the deployed request and data paths. \begin{figure}[htbp] \centering \resizebox{\linewidth}{!}{\input{figures/devopsGiteaFoundation/giteaArchitecture}} \caption{October 15: deployed architecture. The security group admits 443 and 80 from any source and 22 only from approved team addresses. Caddy is the only container publishing host ports; Gitea, the Color Buttons app, and both data volumes are private.} \label{Figure:GiteaArchitecture} \end{figure} \paragraph{Where data lives.} Repositories, attachments, Gitea configuration, and the SQLite database (\path{/data/gitea/gitea.db}) live in the named volume \texttt{inviscid\_gitea\_data}. Certificates and the ACME account live in \texttt{inviscid\_caddy\_data} (with \texttt{inviscid\_caddy\_config}). All three volumes are on the instance's EBS disk, outside the disposable container layers. Deployment files are versioned in \path{docker/gitea/} of the repository. \paragraph{Public and private interfaces.} Public: TCP 443 (HTTPS to Caddy) and TCP 80 (redirect to HTTPS, ACME validation, and the Color Buttons app by IP), both from any source. Restricted: TCP 22 (administrative SSH) from approved team addresses only. Private: Gitea's port 3000 (reachable only on the Compose network and EC2 loopback, for SSH-tunnel administration), the Color Buttons container's port 3000, Caddy's admin port 2019, and the SQLite file, which has no network port. \paragraph{Restart and troubleshooting.} From \path{~/gitea} on EC2: \begin{minted}{bash} sudo docker compose ps sudo docker compose logs --tail=40 caddy sudo docker compose logs --tail=40 gitea sudo docker compose restart gitea # or caddy; volumes are kept \end{minted} If \url{https://git.inviscid.dev} fails, check in order: (1) both services are running; (2) the Caddy log shows no certificate or upstream errors; (3) the Gitea log shows it listening on port 3000; (4) \texttt{curl -I http://127.0.0.1:3000/} on EC2 returns 200, which isolates Gitea from Caddy; (5) DNS still resolves to the Elastic IP and the security group still admits 443. \paragraph{Persistence test versus backup/restore.} The container-replacement test shows that data survives when the Gitea \emph{container} is discarded, because the named volume is reattached. It does not protect against loss of the volume, the EBS disk, or the instance, and it creates no copy elsewhere. A backup/restore test would copy the volume and database to separate storage and prove recovery into a different instance; that optional exercise was not performed. \paragraph{Costs and cleanup.} Victor owns the class AWS environment; compute, EBS, and public IPv4 are billable and covered by budget alerts rather than a spending cap. End-of-course cleanup must include the instance, unneeded storage, and release of the Elastic IP; stopping the instance alone is not full cleanup. The domain renews automatically on October 15, 2027 unless auto-renewal is turned off. \section{Manual Quality and Controls} This version updates the Hosts and Passwords tables, as recorded in the document history. No passwords, private keys, database contents, or generated \texttt{app.ini} are included, and team IP addresses are redacted. Material AI help is recorded in \ChapterRef{AIPromptLog}. \paragraph{Failed attempts and corrective actions.} \begin{itemize}[nosep] \item Victor's SSH from campus timed out after his public IP changed; a campus-specific rule was added, and his later access succeeded. \item Payton's first SSH login failed (\texttt{Permission denied (publickey)}); Victor authorized Payton's public key. \item EC2 Instance Connect failed because the team's AWS role lacks \texttt{ec2-instance-connect:SendSSHPublicKey}; the temporary security-group rule added for it was removed immediately. \item Caddy could not use port 80 while Color Buttons published it; the app was moved behind Caddy on the private network. \item The server's \texttt{compose.yaml} differed from the repository copy by a stray Windows line ending; the file was rewritten with LF endings, and the checksums now match. \end{itemize} \paragraph{Technical references.} \href{https://docs.gitea.com/installation/install-with-docker/}{Gitea Docker installation}; \href{https://docs.gitea.com/administration/config-cheat-sheet/}{Gitea configuration}; \href{https://caddyserver.com/docs/running\#docker-compose}{Caddy with Docker Compose}; \href{https://caddyserver.com/docs/automatic-https}{Caddy automatic HTTPS}; \href{https://education.github.com/pack}{GitHub Student Developer Pack}; \href{https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html}{AWS Elastic IP documentation}.